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.
- package/README.md +186 -18
- package/dist/src/cli/detect.js +12 -3
- package/dist/src/cli/prompt.js +28 -3
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +44 -7
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +75 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +28 -0
- package/dist/src/db/sessions.js +61 -0
- package/dist/src/git/workspace.js +32 -6
- package/dist/src/heuristics/android-root.js +49 -0
- package/dist/src/heuristics/android-signing.js +98 -0
- package/dist/src/heuristics/appfile.js +60 -0
- package/dist/src/heuristics/blocking-actions.js +2 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +69 -10
- package/dist/src/heuristics/readiness.js +554 -25
- package/dist/src/main.js +10 -1
- package/dist/src/runner/gradle-properties.js +187 -0
- package/dist/src/runner/materialise.js +92 -0
- package/dist/src/runner/orchestrate.js +91 -2
- package/dist/src/secrets/vault.js +106 -5
- package/dist/src/server/app.js +30 -3
- package/dist/src/server/auth.js +50 -10
- package/dist/src/server/permissions.js +2 -0
- package/dist/src/server/required-secrets.js +30 -0
- package/dist/src/server/routes/account.js +57 -0
- package/dist/src/server/routes/credentials.js +108 -0
- package/dist/src/server/routes/readiness.js +162 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/sidecar/bridge.js +21 -1
- package/dist/src/sidecar/fastlane-dir.js +23 -0
- package/dist/src/sidecar/lanes.js +6 -0
- package/dist/src/sidecar/uses.js +21 -5
- package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-DynuBC2l.js} +1 -1
- package/dist/web/assets/index-CpwrNE-K.css +1 -0
- package/dist/web/assets/index-lyZs-Y7J.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/ruby/introspect.rb +122 -9
- package/dist/web/assets/index-De6sxx6G.css +0 -1
- package/dist/web/assets/index-TWRQ1cJg.js +0 -62
package/README.md
CHANGED
|
@@ -25,6 +25,11 @@ the Fastfile you already have and asks *your* fastlane what it can do — plugin
|
|
|
25
25
|
hard-codes no knowledge of fastlane at all, so upgrading fastlane or adding a plugin needs no
|
|
26
26
|
change here.
|
|
27
27
|
|
|
28
|
+
The adaptation goes one way. A repository that builds today keeps building unedited: signing
|
|
29
|
+
credentials reach your lanes under the variable names your Fastfile already reads, and where
|
|
30
|
+
Laneyard needs to know something no file can tell it, it asks on a form rather than asking you to
|
|
31
|
+
change the file.
|
|
32
|
+
|
|
28
33
|
## Where it fits
|
|
29
34
|
|
|
30
35
|
| | Laneyard | Hosted CI | Self-hosted runner |
|
|
@@ -150,6 +155,13 @@ shell history. Without `--role`, the account is a builder.
|
|
|
150
155
|
Two things are refused, in the API and on the command line alike: removing the last admin, and
|
|
151
156
|
demoting the last admin. A server nobody can administer cannot be repaired from the interface.
|
|
152
157
|
|
|
158
|
+
Anyone changes their own password from **your account**, reached by clicking your name in the
|
|
159
|
+
header — a builder included, since that page is about one person rather than about the server's
|
|
160
|
+
list of people. It asks for the current password even though you are already signed in: a session
|
|
161
|
+
is a cookie in a browser that may have been left open on a desk. Doing it ends every other session
|
|
162
|
+
that account has, and leaves the page you did it on signed in. That is how the random password
|
|
163
|
+
`laneyard setup` printed once stops being a string on a sticky note.
|
|
164
|
+
|
|
153
165
|
Removing an account ends its sessions immediately — "remove the account" and "revoke access" are
|
|
154
166
|
the same act. So does editing `config.yml` by hand: every request looks the account up again, so
|
|
155
167
|
a demotion takes effect at once rather than at the next restart.
|
|
@@ -180,6 +192,13 @@ platforms: [ios] # or `[android]`, or both
|
|
|
180
192
|
asked for an App Store Connect key. Left out, Laneyard looks at the repository — an Xcode project
|
|
181
193
|
means iOS, a Gradle build means Android — and reports what it found rather than assuming.
|
|
182
194
|
|
|
195
|
+
It looks **beside the Fastfile**, not at the repository root, because that is where an app keeps
|
|
196
|
+
its platform folders: `ios/` and `fastlane/` are siblings, and both move together when the app is
|
|
197
|
+
one directory of a monorepo. So `app/fastlane/Fastfile` alongside `app/ios/Runner.xcodeproj` is
|
|
198
|
+
found, and a project configured with `ios/fastlane` reports iOS alone rather than being shown the
|
|
199
|
+
Android section on the strength of a sibling folder its lanes never touch. When that guess is
|
|
200
|
+
wrong, `platforms` is read first and settles it.
|
|
201
|
+
|
|
183
202
|
Field by field, the repository file wins over the server block, which wins over the defaults. Any
|
|
184
203
|
field of `laneyard.yml` may also be written in the server block, so a repository you would rather
|
|
185
204
|
not touch can be configured entirely from `config.yml`.
|
|
@@ -189,8 +208,9 @@ reported and the last valid configuration stays live — a typo never takes the
|
|
|
189
208
|
|
|
190
209
|
### Secrets
|
|
191
210
|
|
|
192
|
-
|
|
193
|
-
project or from the command line
|
|
211
|
+
The variables your lanes read do not live in a file. They go into an encrypted vault, from the
|
|
212
|
+
Secrets tab of a project or from the command line — the files a project signs with go in the same
|
|
213
|
+
vault, as blocks, and have a section of their own below:
|
|
194
214
|
|
|
195
215
|
```bash
|
|
196
216
|
laneyard secret set MATCH_PASSWORD --project cartes-ios # reads the value from standard input
|
|
@@ -201,25 +221,117 @@ The value is never an argument: a command line ends up in `~/.zsh_history` and i
|
|
|
201
221
|
`ps`. Typing the command alone leaves you at a blank line — type or paste the value, then
|
|
202
222
|
`Ctrl-D`.
|
|
203
223
|
|
|
204
|
-
**
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
224
|
+
**Reading one back.** The vault is write-only for anything you called a secret: the server never
|
|
225
|
+
sends a masked value back, so the interface has nothing to uncover and no browser ever holds one.
|
|
226
|
+
|
|
227
|
+
Not everything stored here is a secret, though — `APP_VERSION`, `SENTRY_ORG`, an issuer id are
|
|
228
|
+
identifiers, and being unable to check what an import stored makes the import something you take on
|
|
229
|
+
faith. So the line is the one you drew yourself: a value kept out of the logs is never returned; a
|
|
230
|
+
value you stored without that is shown on request, one named key at a time. `mask` and `unmask`
|
|
231
|
+
change which it is without touching the value — otherwise revealing something would mean first
|
|
232
|
+
retyping the value you were trying to read.
|
|
233
|
+
|
|
234
|
+
**Bring the ones you already have.** A project that builds today has its variables in
|
|
235
|
+
`fastlane/.env` — gitignored, on one laptop, and therefore absent from the clone a build runs
|
|
236
|
+
from. From that working copy:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
laneyard secret import --project cartes-ios # shows what it would store
|
|
240
|
+
laneyard secret import --project cartes-ios --yes # stores it
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
It runs from the CLI because that is where the `.env` is; the server only ever sees a clone. A
|
|
244
|
+
variable naming a service account JSON has the **file's contents** stored, under `SUPPLY_JSON_KEY_DATA`
|
|
245
|
+
— a name supply reads on its own — because a path does not travel to another machine. A variable
|
|
246
|
+
naming a `.p8` is reported and left alone: no action in fastlane reads a `.p8` out of an environment
|
|
247
|
+
variable, and that credential belongs in a signing block, described below. Everything stored is
|
|
248
|
+
masked, and nothing is printed but names.
|
|
249
|
+
|
|
250
|
+
Nothing in your lanes has to change afterwards. `key_filepath:` and `json_key:` keep working as
|
|
251
|
+
written — a signing block puts a real file back on disk for the length of a run.
|
|
211
252
|
|
|
212
253
|
A secret becomes an environment variable for every run of the project it belongs to. Without
|
|
213
254
|
`--project` it applies to every project; a project secret of the same name wins over a global
|
|
214
255
|
one. Secrets are kept out of the logs unless you pass `--no-mask`, and a masked value must be at
|
|
215
256
|
least four characters long — see below.
|
|
216
257
|
|
|
258
|
+
### Signing credentials
|
|
259
|
+
|
|
260
|
+
A signing credential is not a string. An Android keystore is bytes that Gradle reads through a path,
|
|
261
|
+
and a `.p8` is useless without the key id and the issuer id that go with it — so these are stored as
|
|
262
|
+
blocks: one file, plus the handful of fields that make it usable, taken whole or refused. A keystore
|
|
263
|
+
stored without its alias is not a partial success; it is a build that fails in a month.
|
|
264
|
+
|
|
265
|
+
| block | the file | the fields beside it |
|
|
266
|
+
| ------------------------------ | ---------------------- | ------------------------------------------- |
|
|
267
|
+
| *app store connect key* | `.p8` | key id, issuer id |
|
|
268
|
+
| *android upload keystore* | `.jks` or `.keystore` | key alias, store password, key password |
|
|
269
|
+
| *play store service account* | JSON | — |
|
|
270
|
+
|
|
271
|
+
They live in the **signing** part of a project's Secrets tab, beside the variables and the secrets.
|
|
272
|
+
The file is encrypted at rest like everything else in the vault and never comes back out to a
|
|
273
|
+
browser: a stored block shows its file name and nothing more, so replacing one means giving it again
|
|
274
|
+
in full.
|
|
275
|
+
|
|
276
|
+
A variable a block has made redundant is said to be so, beside the row and in one sentence:
|
|
277
|
+
`SUPPLY_JSON_KEY_DATA` once a Play block applies — it still works, but the same credential is then
|
|
278
|
+
stored twice — and `APP_STORE_CONNECT_API_KEY_P8`, which no action in fastlane has ever read.
|
|
279
|
+
Neither is removed for you: the row is yours, and the button to drop it is on the same line.
|
|
280
|
+
|
|
281
|
+
**A block becomes real files, for the length of a run.** Gradle's `storeFile` is a path, and
|
|
282
|
+
`app_store_connect_api_key` wants a path, so a credential that exists only as ciphertext in a
|
|
283
|
+
database cannot be used by anything. Each block that applies is written into
|
|
284
|
+
`~/.laneyard/runs/<run id>/secrets/`, mode `600` in a `700` directory, and that directory is removed
|
|
285
|
+
when the run ends — whether it passed, failed, was cancelled or timed out. Every applicable block is
|
|
286
|
+
written, whether or not the lane looks like it needs one: a Fastfile can reach anything through `sh`
|
|
287
|
+
or a plugin, and a guess of "not needed" that is wrong is a debug-signed artifact rather than a
|
|
288
|
+
missing variable.
|
|
289
|
+
|
|
290
|
+
**It reaches your lanes under the names your project already reads.** Each block's form arrives
|
|
291
|
+
pre-filled with the names fastlane itself declares — `APP_STORE_CONNECT_API_KEY_KEY_FILEPATH`,
|
|
292
|
+
`APP_STORE_CONNECT_API_KEY_KEY_ID`, `APP_STORE_CONNECT_API_KEY_ISSUER_ID` for the key, and
|
|
293
|
+
`SUPPLY_JSON_KEY` for the service account — and every one of them is editable. A Fastfile written
|
|
294
|
+
around `ENV.fetch("ASC_KEY_FILEPATH")` is not a Fastfile doing it wrong: you say so on that form,
|
|
295
|
+
rather than being asked to rename anything in the repository. The name stored with the block is the
|
|
296
|
+
only name exported, and no default is emitted alongside it as a courtesy — that courtesy is what
|
|
297
|
+
would make a typo in the configured name look like it had worked.
|
|
298
|
+
|
|
299
|
+
Nothing in fastlane reads a keystore by convention, so the keystore's names are Laneyard's own —
|
|
300
|
+
`ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` —
|
|
301
|
+
and the same rule applies to them.
|
|
302
|
+
|
|
303
|
+
**The keystore block can also supply `key.properties`.** The Flutter documentation's own build
|
|
304
|
+
script signs with the release config when that file exists and with the debug config when it does
|
|
305
|
+
not, and gitignores it — so on a build server it is always absent. Rather than telling you to
|
|
306
|
+
rewrite your build script, Laneyard writes the file the script is already looking for, for the
|
|
307
|
+
length of the run, out of the keystore block. Two things about that file cannot be read out of a
|
|
308
|
+
build script and are asked for on the block instead: where it goes, when the script names it in a
|
|
309
|
+
way that leaves the directory unresolved, and what the keys inside it are called, which start from
|
|
310
|
+
the Flutter documentation's four. Asking at configuration time is allowed; requiring a change to
|
|
311
|
+
your repository is not.
|
|
312
|
+
|
|
313
|
+
**Only the projects that sign need any of this.** fastlane is not only for shipping to stores —
|
|
314
|
+
lanes take screenshots, run tests, sync certificates — so the three blocks are an offer rather than
|
|
315
|
+
a gate. A project that wants an artifact out of a Gradle build needs the keystore and nothing else,
|
|
316
|
+
and three untouched circles are not three things it is failing.
|
|
317
|
+
|
|
318
|
+
A block stored on a project belongs to that project. One stored globally applies to every project,
|
|
319
|
+
and a project's own block wins over it. Both are admin-only, like the rest of the vault.
|
|
320
|
+
|
|
217
321
|
### Readiness
|
|
218
322
|
|
|
219
323
|
Every project has a Readiness tab: what stands between it and a build that runs while nobody
|
|
220
324
|
watches. Only the checks that apply to the project are shown — an Android project is never asked
|
|
221
325
|
for an App Store Connect key, because one irrelevant warning teaches you to ignore the screen.
|
|
222
326
|
|
|
327
|
+
**What a tick means.** The checks read your Fastfile, following a lane into the methods that
|
|
328
|
+
Fastfile defines — factoring your lanes into `def deploy_ios` is good practice, not something that
|
|
329
|
+
should make Laneyard blind. Two things stay out of reach and always will: `import`/`import_from_git`
|
|
330
|
+
brings in lanes written elsewhere, and `fastlane/actions/` holds actions whose names mean nothing to
|
|
331
|
+
a reader that has only seen the Fastfile. Where either applies, a check that found nothing says
|
|
332
|
+
*could not tell* rather than ticking. A green tick here means "looked, and it is fine" — never
|
|
333
|
+
"looked, and saw nothing".
|
|
334
|
+
|
|
223
335
|
Always:
|
|
224
336
|
|
|
225
337
|
- **the repository** answers `git ls-remote` without asking for credentials — a run that meets a
|
|
@@ -227,20 +339,61 @@ Always:
|
|
|
227
339
|
- **dependencies** are installable: `bundle check` against your Gemfile, or the `fastlane` a run
|
|
228
340
|
would otherwise find on the PATH;
|
|
229
341
|
- **no lane calls an action known to stop and ask** — `prompt`, `sigh`, `cert`, a writable
|
|
230
|
-
`match`, an upload waiting for its summary to be confirmed
|
|
342
|
+
`match`, an upload waiting for its summary to be confirmed;
|
|
343
|
+
- **the variables the lanes read** are in the vault. Every `ENV.fetch("…")` a lane reaches is
|
|
344
|
+
collected and looked up. This is the check for the commonest way a project that works on your
|
|
345
|
+
laptop fails on a build server: the variables live in `fastlane/.env`, that file is gitignored,
|
|
346
|
+
and it never reaches the clone a build runs from — so the run stops at the first one with
|
|
347
|
+
nothing on screen to say why.
|
|
348
|
+
|
|
349
|
+
Two things a Fastfile cannot tell you, and two places to say them. A variable read by a tool the
|
|
350
|
+
lane shells out to — `sentry-cli` and its `SENTRY_AUTH_TOKEN` — is named nowhere in the lanes. A
|
|
351
|
+
committed `fastlane/.env.example` is read for exactly this, since that is what the file is for,
|
|
352
|
+
and `required_secrets` in `laneyard.yml` covers whatever it does not. A variable found only in
|
|
353
|
+
the server's own environment is reported rather than ticked over: it works, but it works because
|
|
354
|
+
of how this server was started.
|
|
355
|
+
|
|
356
|
+
A name a signing block exports counts as being in the vault, since that is where the block is:
|
|
357
|
+
a lane reading `SUPPLY_JSON_KEY` with a Play block stored is not asked for it again, on this
|
|
358
|
+
checklist or on the Secrets tab. The two read the same answer.
|
|
231
359
|
|
|
232
360
|
On iOS:
|
|
233
361
|
|
|
234
|
-
- **App Store Connect** has an API key
|
|
235
|
-
|
|
362
|
+
- **App Store Connect** has an API key. The vault is checked first and is the only thing that
|
|
363
|
+
earns a tick — a signing block, or the variables a project stored before blocks existed, since
|
|
364
|
+
fastlane reads those exactly as it did. A project that configured fastlane long before it met
|
|
365
|
+
Laneyard keeps its key elsewhere, so the lanes are read for `app_store_connect_api_key` and for a `key_filepath` or
|
|
366
|
+
`api_key_path` argument, and the repository for a `.p8`. Any of those is reported as *could not
|
|
367
|
+
tell*, not as a warning: a path in a Fastfile says a key was arranged, not that the file is on
|
|
368
|
+
this machine. An Appfile holding only an `apple_id` is a warning — that is the account
|
|
369
|
+
two-factor authentication will stop the run to ask about. One name is called out rather than
|
|
370
|
+
accepted: a value stored under `APP_STORE_CONNECT_API_KEY_P8` used to earn a tick here, and no
|
|
371
|
+
action in fastlane has ever read a variable of that name — the check now says so, because being
|
|
372
|
+
told to redo the work beats a screen that has quietly gone silent about it;
|
|
236
373
|
- **match** has its `MATCH_PASSWORD` stored and is called `readonly`, so it fetches certificates
|
|
237
374
|
instead of trying to create them.
|
|
238
375
|
|
|
239
376
|
On Android:
|
|
240
377
|
|
|
241
378
|
- **the keystore** is reachable without a prompt: a lane handing `gradle` a `storeFile` needs a
|
|
242
|
-
passphrase, and one that is neither in the call nor in the vault makes gradle stop and ask
|
|
243
|
-
|
|
379
|
+
passphrase, and one that is neither in the call nor in the vault makes gradle stop and ask. A
|
|
380
|
+
keystore block settles this before the lanes are read at all — the file and both passphrases
|
|
381
|
+
reach the run together, so nothing can stop and ask, whichever lane runs;
|
|
382
|
+
- **the release is signed with the release key.** The one check here whose failure is silent:
|
|
383
|
+
the Flutter documentation's own snippet signs with the release config when `key.properties`
|
|
384
|
+
exists and with the *debug* config when it does not — and gitignores `key.properties`, so it is
|
|
385
|
+
absent from every clone. The build then succeeds, produces an artifact signed with the debug key,
|
|
386
|
+
and the rejection arrives from the store minutes later saying nothing about signing. This reads
|
|
387
|
+
the Gradle file as text and says so before the build, not after — and then, for a project whose
|
|
388
|
+
keystore is stored here, writes the `key.properties` that build is already asking for, for the
|
|
389
|
+
length of the run. Your build script is not asked to change: the file arrives where it looks for
|
|
390
|
+
it, marked `# written by laneyard, do not commit`, and is removed when fastlane stops. A file of
|
|
391
|
+
your own without that marker is never written over and never deleted;
|
|
392
|
+
- **the Play Store service account** is there when a lane calls `upload_to_play_store`. The vault
|
|
393
|
+
first — a block, or a `SUPPLY_JSON_KEY` variable stored before blocks existed — then the
|
|
394
|
+
`json_key` argument in the call, then the **Appfile**: `json_key_file` and `json_key_data`, which
|
|
395
|
+
is where a long-standing project almost always keeps it. Only the vault is a tick; the other two
|
|
396
|
+
are *could not tell*, for the same reason as above.
|
|
244
397
|
|
|
245
398
|
Like the iOS ones, the Android checks read **literal arguments only**. `gradle(storePassword:
|
|
246
399
|
ENV["PW"])` is reported as undetermined, never guessed at: a checklist that guesses gets believed.
|
|
@@ -258,7 +411,9 @@ growing a second copy of its form.
|
|
|
258
411
|
green and `match(readonly: false)` is a warning, but `match(readonly: ENV["RO"])` has no value
|
|
259
412
|
until the lane runs, so it is reported as undetermined — `○`, with the reason — rather than
|
|
260
413
|
guessed either way. The same applies to anything a lane computes: a checklist that guesses gets
|
|
261
|
-
believed, and then it is worse than no checklist. Android signing is
|
|
414
|
+
believed, and then it is worse than no checklist. Android signing is read out of the Gradle build
|
|
415
|
+
script rather than the Fastfile, since that is where it lives — and read as text, because running
|
|
416
|
+
someone's build script to ask it a question is not something a checklist may do.
|
|
262
417
|
|
|
263
418
|
### The Fastfile
|
|
264
419
|
|
|
@@ -312,8 +467,9 @@ Read this before putting Laneyard on a network.
|
|
|
312
467
|
it from your laptop, behind a password. Do not expose it publicly. If you need remote access,
|
|
313
468
|
put it behind a VPN or an SSH tunnel.
|
|
314
469
|
- **Passwords** are stored as scrypt hashes and repeated failures are throttled, per account, so
|
|
315
|
-
hammering one name cannot lock out the others. Sessions
|
|
316
|
-
|
|
470
|
+
hammering one name cannot lock out the others. Sessions survive a restart, and what is stored is
|
|
471
|
+
a SHA-256 of the token rather than the token: a stolen `laneyard.db` is a list of digests, not a
|
|
472
|
+
ring of working keys.
|
|
317
473
|
- **A role is enforced by the server, not by the interface.** One table names the routes that
|
|
318
474
|
require an admin, and one hook is the only thing that reads it — there is no permission check
|
|
319
475
|
hidden inside a handler. What a builder is not shown is also what a builder is refused.
|
|
@@ -322,6 +478,11 @@ Read this before putting Laneyard on a network.
|
|
|
322
478
|
else can read it. Someone who walks off with `laneyard.db` gets ciphertext. Nothing else in the
|
|
323
479
|
process holds plaintext: the store, the API and the interface deal in names only, and no route
|
|
324
480
|
ever sends a value back — which is why the Secrets tab has no reveal button.
|
|
481
|
+
- **A signing block is on disk only while a run needs it.** A keystore has no string form, so the
|
|
482
|
+
file is written into `~/.laneyard/runs/<run id>/secrets/`, mode `600` inside a `700` directory,
|
|
483
|
+
and that directory goes when the run ends. The block's secret fields — the two keystore
|
|
484
|
+
passphrases — are removed from a run's output the same way a masked secret is, because gradle is
|
|
485
|
+
perfectly willing to echo one back on failure.
|
|
325
486
|
- **Masked values are removed from output before it is written, not when it is displayed.** The
|
|
326
487
|
substitution happens once, at the point where a run's output fans out, so the log file on disk,
|
|
327
488
|
the live stream to your browser and the stored error summary all contain `••••••` and never the
|
|
@@ -341,6 +502,12 @@ What this does *not* cover, stated plainly:
|
|
|
341
502
|
pretending. Store it unmasked if it genuinely does not matter.
|
|
342
503
|
- **Anything fastlane prints that is not a stored secret is stored in the clear**, under
|
|
343
504
|
`~/.laneyard/logs/`.
|
|
505
|
+
- **`key.properties` is written into the workspace, and it holds passwords.** It is the one
|
|
506
|
+
credential Laneyard puts in the clone rather than in the run's own directory, because Gradle
|
|
507
|
+
resolves that path relative to the build. It is mode `600`, carries a marker line as its first
|
|
508
|
+
line, is removed when the run ends, and is swept for again at the start of the next run in case a
|
|
509
|
+
server was killed mid-build. A file of yours without that marker is never written over and never
|
|
510
|
+
removed.
|
|
344
511
|
|
|
345
512
|
## Status
|
|
346
513
|
|
|
@@ -353,7 +520,8 @@ What this does *not* cover, stated plainly:
|
|
|
353
520
|
- `✓` build queue, cancellation, timeouts surfaced in the UI
|
|
354
521
|
- `✓` a checklist that gets a project running unattended
|
|
355
522
|
- `✓` edit the Fastfile in the browser, verified on every save
|
|
356
|
-
- `✓`
|
|
523
|
+
- `✓` signing credentials stored whole — the file and the fields beside it — written to disk for
|
|
524
|
+
the length of a run and exported under the names your project already reads
|
|
357
525
|
- `✓` remove a project from the interface, without touching its history
|
|
358
526
|
- `✓` named accounts, with a builder role that never sees a credential
|
|
359
527
|
- `○` git-triggered and scheduled builds
|
package/dist/src/cli/detect.js
CHANGED
|
@@ -3,7 +3,7 @@ import { access, realpath } from "node:fs/promises";
|
|
|
3
3
|
import { basename, join, relative, sep } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { glob } from "tinyglobby";
|
|
6
|
-
import { detectPlatforms } from "../heuristics/platforms.js";
|
|
6
|
+
import { detectPlatforms, searchDir } from "../heuristics/platforms.js";
|
|
7
7
|
const exec = promisify(execFile);
|
|
8
8
|
const exists = async (p) => {
|
|
9
9
|
try {
|
|
@@ -59,7 +59,7 @@ const toRepoPath = (root, absolute) => relative(root, absolute).split(sep).join(
|
|
|
59
59
|
* sees and can correct before it's written.
|
|
60
60
|
*/
|
|
61
61
|
export async function detectProject(dir) {
|
|
62
|
-
// Without a repository there is nothing to clone; `
|
|
62
|
+
// Without a repository there is nothing to clone; `runSetupCommand` refuses
|
|
63
63
|
// shortly after, so falling back to `dir` here only keeps this function total.
|
|
64
64
|
// Both sides are resolved before being compared: on macOS the temporary
|
|
65
65
|
// directory is a symlink, and git always answers with the real path — so a
|
|
@@ -78,7 +78,16 @@ export async function detectProject(dir) {
|
|
|
78
78
|
const fastlaneDir = fastfile
|
|
79
79
|
? toRepoPath(root, await realpath(join(fastfile, "..")).catch(() => join(fastfile, "..")))
|
|
80
80
|
: null;
|
|
81
|
-
|
|
81
|
+
// From beside the Fastfile rather than from where the user is standing: the
|
|
82
|
+
// markers are the app's siblings, and `*/*/fastlane/Fastfile` above already
|
|
83
|
+
// allows the app to be two directories down, out of reach of the two-level
|
|
84
|
+
// marker globs from here.
|
|
85
|
+
// `fastlaneDir` is relative to the repository root, while the listing happens
|
|
86
|
+
// where the user is standing — the same directory only when they are at the
|
|
87
|
+
// root. The absolute Fastfile path needs no such reconciling, so the app root
|
|
88
|
+
// is taken from it: the Fastfile's grandparent is the app.
|
|
89
|
+
const appRoot = fastfile ? toRepoPath(dir, join(fastfile, "..", "..")) : null;
|
|
90
|
+
const platforms = await detectPlatforms(findIn(searchDir(dir, appRoot)));
|
|
82
91
|
const isIos = platforms.includes("ios");
|
|
83
92
|
const isAndroid = platforms.includes("android");
|
|
84
93
|
// Artifact patterns are anchored to the sub-project too. In a monorepo an
|
package/dist/src/cli/prompt.js
CHANGED
|
@@ -1,7 +1,32 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when the user walks away from a question — Ctrl-C or Ctrl-D. Not a
|
|
4
|
+
* failure: the caller turns it into one sentence and a 130 exit code.
|
|
5
|
+
*/
|
|
6
|
+
export class PromptAborted extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("interrupted");
|
|
9
|
+
this.name = "PromptAborted";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
2
12
|
/** Reads from the real terminal. */
|
|
3
13
|
export function terminalAsker() {
|
|
4
14
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
15
|
+
// Left alone, Ctrl-C closes the interface while `rl.question` stays pending
|
|
16
|
+
// forever, and node reports that as "Detected unsettled top-level await" —
|
|
17
|
+
// an internal complaint about our code, printed at someone who just pressed
|
|
18
|
+
// Ctrl-C. Aborting the question makes it a rejection we can answer for.
|
|
19
|
+
const interrupted = new AbortController();
|
|
20
|
+
rl.on("SIGINT", () => rl.close());
|
|
21
|
+
rl.on("close", () => interrupted.abort());
|
|
22
|
+
async function question(prompt) {
|
|
23
|
+
try {
|
|
24
|
+
return await rl.question(prompt, { signal: interrupted.signal });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new PromptAborted();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
5
30
|
return {
|
|
6
31
|
async ask(label, proposed, hint) {
|
|
7
32
|
if (hint)
|
|
@@ -9,12 +34,12 @@ export function terminalAsker() {
|
|
|
9
34
|
// The proposal is shown in the prompt rather than typed for the user:
|
|
10
35
|
// pressing Return accepts it, which is what someone does nine times out
|
|
11
36
|
// of ten, and correcting it costs one line.
|
|
12
|
-
const answer = (await
|
|
37
|
+
const answer = (await question(` ${label} [${proposed}]: `)).trim();
|
|
13
38
|
return answer === "" ? proposed : answer;
|
|
14
39
|
},
|
|
15
|
-
async confirm(
|
|
40
|
+
async confirm(question_, defaultYes) {
|
|
16
41
|
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
|
|
17
|
-
const answer = (await
|
|
42
|
+
const answer = (await question(`${question_} ${suffix} `)).trim().toLowerCase();
|
|
18
43
|
if (answer === "")
|
|
19
44
|
return defaultYes;
|
|
20
45
|
return answer.startsWith("y");
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { parseEnvExample } from "../heuristics/env-example.js";
|
|
4
|
+
/**
|
|
5
|
+
* Bringing an existing `fastlane/.env` into the vault.
|
|
6
|
+
*
|
|
7
|
+
* Every project that already builds has its variables somewhere, and that
|
|
8
|
+
* somewhere is almost always `fastlane/.env` — gitignored, on one laptop, and
|
|
9
|
+
* therefore absent from the clone a build server works from. Typing eight
|
|
10
|
+
* values into a web form to reproduce a file that is already on disk is a poor
|
|
11
|
+
* way to start, and the sort of chore where one typo costs an evening.
|
|
12
|
+
*
|
|
13
|
+
* This runs from the CLI and not from the server on purpose: the `.env` exists
|
|
14
|
+
* where the working copy is, and the server only ever sees a clone.
|
|
15
|
+
*
|
|
16
|
+
* Values are never printed, never logged, and never passed as arguments. What
|
|
17
|
+
* the command shows is names.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The variables whose value is a *path*, and the name their *contents* belong
|
|
21
|
+
* under.
|
|
22
|
+
*
|
|
23
|
+
* This is the translation that makes an import worth doing. `SUPPLY_JSON_KEY`
|
|
24
|
+
* points at a service account JSON on this laptop; copied verbatim into the
|
|
25
|
+
* vault it would point at nothing on the build machine, and the run would fail
|
|
26
|
+
* exactly as it does today. What has to travel is the file, so the file is
|
|
27
|
+
* read and stored under `SUPPLY_JSON_KEY_DATA` — a name supply reads on its
|
|
28
|
+
* own, verified against fastlane 2.237.
|
|
29
|
+
*
|
|
30
|
+
* There is no such entry for a `.p8`. `APP_STORE_CONNECT_API_KEY_P8` was one —
|
|
31
|
+
* an earlier version of this interface invented it, no action in fastlane has
|
|
32
|
+
* ever declared it, and the interface has since dropped it. See `P8_PATH_NAMES`
|
|
33
|
+
* for what a `.p8` path becomes instead.
|
|
34
|
+
*/
|
|
35
|
+
export const PATH_TO_CONTENTS = {
|
|
36
|
+
SUPPLY_JSON_KEY: "SUPPLY_JSON_KEY_DATA",
|
|
37
|
+
GOOGLE_APPLICATION_CREDENTIALS: "SUPPLY_JSON_KEY_DATA",
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Variables that name a `.p8` the way projects and this interface's own
|
|
41
|
+
* earlier version did.
|
|
42
|
+
*
|
|
43
|
+
* None of them is a name fastlane reads: the App Store Connect action takes
|
|
44
|
+
* its key from `key_id`, `issuer_id` and a file, not from an environment
|
|
45
|
+
* variable an import could invent on its own. What this command can do
|
|
46
|
+
* honestly is find the file and say where it belongs — an App Store Connect
|
|
47
|
+
* key block, uploaded from the secrets tab with the two fields the file alone
|
|
48
|
+
* cannot supply.
|
|
49
|
+
*/
|
|
50
|
+
export const P8_PATH_NAMES = new Set([
|
|
51
|
+
"ASC_KEY_FILEPATH",
|
|
52
|
+
"APP_STORE_CONNECT_API_KEY_PATH",
|
|
53
|
+
"APP_STORE_CONNECT_API_KEY_FILEPATH",
|
|
54
|
+
]);
|
|
55
|
+
/**
|
|
56
|
+
* Reads a `.env` into a name → value map.
|
|
57
|
+
*
|
|
58
|
+
* Shares `parseEnvExample`'s idea of what a line is, and adds the values, which
|
|
59
|
+
* that one deliberately drops. Quotes are stripped because a `.env` written by
|
|
60
|
+
* hand often has them and fastlane's own dotenv strips them too — a value that
|
|
61
|
+
* silently kept its quotes is a credential that never works, with nothing on
|
|
62
|
+
* screen to say why.
|
|
63
|
+
*/
|
|
64
|
+
export function parseEnvFile(content) {
|
|
65
|
+
const out = new Map();
|
|
66
|
+
for (const name of parseEnvExample(content)) {
|
|
67
|
+
// Re-scan for this name's value; `parseEnvExample` already vouched for the
|
|
68
|
+
// name, so this only has to find the last assignment, the way dotenv does.
|
|
69
|
+
for (const raw of content.split("\n")) {
|
|
70
|
+
const line = raw.trim().replace(/^export\s+/, "");
|
|
71
|
+
const eq = line.indexOf("=");
|
|
72
|
+
if (eq <= 0 || line.slice(0, eq).trim() !== name)
|
|
73
|
+
continue;
|
|
74
|
+
let value = line.slice(eq + 1).trim();
|
|
75
|
+
const quoted = /^(["'])(.*)\1$/.exec(value);
|
|
76
|
+
if (quoted)
|
|
77
|
+
value = quoted[2];
|
|
78
|
+
out.set(name, value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Decides what would be stored, without storing anything.
|
|
85
|
+
*
|
|
86
|
+
* Separate from the writing so the command can show its work first. An import
|
|
87
|
+
* that reads eight files and writes eight secrets before saying a word is one
|
|
88
|
+
* nobody can check.
|
|
89
|
+
*/
|
|
90
|
+
export async function planImport(env, cwd, existingKeys) {
|
|
91
|
+
const planned = [];
|
|
92
|
+
const already = new Set(existingKeys);
|
|
93
|
+
for (const [name, value] of env) {
|
|
94
|
+
if (value === "")
|
|
95
|
+
continue;
|
|
96
|
+
if (P8_PATH_NAMES.has(name)) {
|
|
97
|
+
const path = isAbsolute(value) ? value : resolve(cwd, value);
|
|
98
|
+
const found = await access(path)
|
|
99
|
+
.then(() => true)
|
|
100
|
+
.catch(() => false);
|
|
101
|
+
if (!found) {
|
|
102
|
+
// Same handling as any other missing file: reported, not skipped in
|
|
103
|
+
// silence, because it is the credential the project most needs.
|
|
104
|
+
planned.push({ key: name, kind: "unresolved-path", path, value });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// The file is real, but nothing is stored under `name` — there is
|
|
108
|
+
// nowhere fastlane would read it from. Left to the caller to say what
|
|
109
|
+
// to do instead, in the same voice as the rest of the plan.
|
|
110
|
+
planned.push({ key: name, kind: "suggest-block", path, value });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const contentsKey = PATH_TO_CONTENTS[name];
|
|
114
|
+
if (contentsKey) {
|
|
115
|
+
const path = isAbsolute(value) ? value : resolve(cwd, value);
|
|
116
|
+
const contents = await readFile(path, "utf8").catch(() => null);
|
|
117
|
+
if (contents === null) {
|
|
118
|
+
// Named a file that is not there. Reported rather than skipped: it is
|
|
119
|
+
// the credential the project most needs, and silence would read as
|
|
120
|
+
// success.
|
|
121
|
+
planned.push({ key: name, kind: "unresolved-path", path, value });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
planned.push({
|
|
125
|
+
key: contentsKey,
|
|
126
|
+
...(contentsKey === name ? {} : { from: name }),
|
|
127
|
+
kind: "file-contents",
|
|
128
|
+
path,
|
|
129
|
+
value: contents,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
planned.push({ key: name, kind: "value", value });
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
planned,
|
|
137
|
+
replacing: planned
|
|
138
|
+
.filter((p) => p.kind !== "unresolved-path" && p.kind !== "suggest-block" && already.has(p.key))
|
|
139
|
+
.map((p) => p.key),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Writes the plan into the vault.
|
|
144
|
+
*
|
|
145
|
+
* Everything is masked. A value that came out of a `.env` is a credential by
|
|
146
|
+
* assumption — that is what the file is for — and the one that turns out not to
|
|
147
|
+
* be secret costs a redacted line in a log, while the reverse costs a leak.
|
|
148
|
+
*/
|
|
149
|
+
export async function applyImport(vault, slug, plan) {
|
|
150
|
+
let stored = 0;
|
|
151
|
+
for (const item of plan.planned) {
|
|
152
|
+
if (item.kind === "unresolved-path" || item.kind === "suggest-block")
|
|
153
|
+
continue;
|
|
154
|
+
await vault.set(slug, item.key, item.value, true);
|
|
155
|
+
stored += 1;
|
|
156
|
+
}
|
|
157
|
+
return stored;
|
|
158
|
+
}
|
|
159
|
+
/** Where a project's `.env` files live, in the order dotenv itself reads them. */
|
|
160
|
+
export function envFilesIn(fastlaneDir) {
|
|
161
|
+
return [join(fastlaneDir, ".env.default"), join(fastlaneDir, ".env")];
|
|
162
|
+
}
|