laneyard 0.2.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 +295 -20
- package/dist/src/cli/detect.js +69 -16
- package/dist/src/cli/prompt.js +61 -0
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +319 -0
- package/dist/src/cli/style.js +31 -0
- package/dist/src/cli/user.js +127 -0
- package/dist/src/config/accounts.js +163 -0
- package/dist/src/config/load.js +61 -1
- package/dist/src/config/schema.js +26 -1
- package/dist/src/config/store.js +10 -0
- package/dist/src/config/yaml.js +14 -0
- 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 +22 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +136 -0
- package/dist/src/heuristics/readiness.js +697 -25
- package/dist/src/main.js +58 -8
- 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 +94 -15
- package/dist/src/server/auth.js +127 -19
- package/dist/src/server/permissions.js +75 -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/projects.js +42 -0
- package/dist/src/server/routes/readiness.js +175 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/server/routes/users.js +64 -0
- package/dist/src/sidecar/bridge.js +37 -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-D5Q4uj4n.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-D9_EL8LA.js +0 -62
- package/dist/web/assets/index-ZUqTX6d1.css +0 -1
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 |
|
|
@@ -58,14 +63,15 @@ Then, from a project you already build with fastlane:
|
|
|
58
63
|
|
|
59
64
|
```bash
|
|
60
65
|
cd ~/code/your-app
|
|
61
|
-
laneyard
|
|
66
|
+
laneyard setup # answer a few questions about this project
|
|
62
67
|
laneyard # start the server
|
|
63
68
|
```
|
|
64
69
|
|
|
65
|
-
`laneyard
|
|
66
|
-
`Gemfile`, an Xcode project or a Gradle build — writes the matching block into
|
|
67
|
-
`~/.laneyard/config.yml
|
|
68
|
-
|
|
70
|
+
`laneyard setup` inspects what is there — the `fastlane` directory even when nested in a monorepo, a
|
|
71
|
+
`Gemfile`, an Xcode project or a Gradle build — and writes the matching block into
|
|
72
|
+
`~/.laneyard/config.yml`. On a machine that has no account yet, it also creates the first admin:
|
|
73
|
+
it asks what to call it and prints its generated password once. Write it down; it is not shown
|
|
74
|
+
again, and nothing stores it.
|
|
69
75
|
|
|
70
76
|
<details>
|
|
71
77
|
<summary>Running from source instead</summary>
|
|
@@ -95,7 +101,9 @@ Laneyard means copying one file, and restoring it means copying it back.
|
|
|
95
101
|
server:
|
|
96
102
|
port: 7890
|
|
97
103
|
bind: 0.0.0.0
|
|
98
|
-
|
|
104
|
+
users: # written by `laneyard setup`, see Accounts
|
|
105
|
+
- { name: martin, role: admin, password_hash: "scrypt$…" }
|
|
106
|
+
- { name: lea, role: builder, password_hash: "scrypt$…" }
|
|
99
107
|
max_concurrent_runs: 1 # only 1 is accepted, see below
|
|
100
108
|
retention: { runs: 50, artifact_days: 30 }
|
|
101
109
|
|
|
@@ -112,9 +120,63 @@ a time across every project — parallel runs would need a working directory per
|
|
|
112
120
|
not exist yet. A larger number is refused when the file loads rather than silently ignored, so a
|
|
113
121
|
server is never configured for builds that never happen.
|
|
114
122
|
|
|
115
|
-
###
|
|
123
|
+
### Accounts
|
|
124
|
+
|
|
125
|
+
Everyone who signs in has a name, a password and one of two roles. Two, because a third role is
|
|
126
|
+
easy to add and impossible to remove.
|
|
127
|
+
|
|
128
|
+
| | **admin** | **builder** |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| start a build, watch it, cancel it | ✓ | ✓ |
|
|
131
|
+
| download artifacts, read logs and the Fastfile | ✓ | ✓ |
|
|
132
|
+
| see the readiness checklist | ✓ | ✓ |
|
|
133
|
+
| read and write secrets | ✓ | |
|
|
134
|
+
| save, commit and push the Fastfile | ✓ | |
|
|
135
|
+
| remove a project | ✓ | |
|
|
136
|
+
| manage accounts | ✓ | |
|
|
137
|
+
|
|
138
|
+
A builder is what you give someone who ships without being trusted with the signing chain: they
|
|
139
|
+
can press the button and watch what happens, and they never see a credential.
|
|
140
|
+
|
|
141
|
+
The interface shows a builder only what a builder can use — the secrets, fastfile and settings
|
|
142
|
+
tabs are not drawn, and neither is the accounts screen. That is courtesy, not security: the
|
|
143
|
+
server refuses those routes on its own, whatever the browser was shown, and the test suite
|
|
144
|
+
proves it for every verb and every spelling of the address.
|
|
145
|
+
|
|
146
|
+
Add and remove accounts from the accounts screen, or from the command line:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
echo "$PASSWORD" | laneyard user add lea --role builder
|
|
150
|
+
```
|
|
116
151
|
|
|
117
|
-
|
|
152
|
+
The password is read from standard input, never taken as an argument — an argument lands in your
|
|
153
|
+
shell history. Without `--role`, the account is a builder.
|
|
154
|
+
|
|
155
|
+
Two things are refused, in the API and on the command line alike: removing the last admin, and
|
|
156
|
+
demoting the last admin. A server nobody can administer cannot be repaired from the interface.
|
|
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
|
+
|
|
165
|
+
Removing an account ends its sessions immediately — "remove the account" and "revoke access" are
|
|
166
|
+
the same act. So does editing `config.yml` by hand: every request looks the account up again, so
|
|
167
|
+
a demotion takes effect at once rather than at the next restart.
|
|
168
|
+
|
|
169
|
+
**Upgrading from 0.2.** An existing `server.password_hash` keeps working, unedited. It is read as
|
|
170
|
+
a single admin account called `admin` — sign in with that name and the password you already have.
|
|
171
|
+
The first time you add someone, the file is rewritten into the `users` form above, comments and
|
|
172
|
+
all. Do not write both forms: a file holding a `password_hash` *and* a `users` list is refused at
|
|
173
|
+
load, because there is no obvious winner.
|
|
174
|
+
|
|
175
|
+
### `laneyard.yml` — in your repository, and committed
|
|
176
|
+
|
|
177
|
+
Build behaviour belongs next to the code, so it can be versioned with it — `laneyard setup`
|
|
178
|
+
writes this file for you, and you should commit it. A colleague who clones the repository then
|
|
179
|
+
builds it the same way, without configuring anything.
|
|
118
180
|
|
|
119
181
|
```yaml
|
|
120
182
|
fastlane_dir: fastlane
|
|
@@ -123,8 +185,20 @@ timeout_minutes: 60
|
|
|
123
185
|
artifact_globs:
|
|
124
186
|
- "build/**/*.ipa"
|
|
125
187
|
- "build/**/*.app.dSYM.zip"
|
|
188
|
+
platforms: [ios] # or `[android]`, or both
|
|
126
189
|
```
|
|
127
190
|
|
|
191
|
+
`platforms` decides which half of the readiness checklist applies: an Android project is never
|
|
192
|
+
asked for an App Store Connect key. Left out, Laneyard looks at the repository — an Xcode project
|
|
193
|
+
means iOS, a Gradle build means Android — and reports what it found rather than assuming.
|
|
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
|
+
|
|
128
202
|
Field by field, the repository file wins over the server block, which wins over the defaults. Any
|
|
129
203
|
field of `laneyard.yml` may also be written in the server block, so a repository you would rather
|
|
130
204
|
not touch can be configured entirely from `config.yml`.
|
|
@@ -134,8 +208,9 @@ reported and the last valid configuration stays live — a typo never takes the
|
|
|
134
208
|
|
|
135
209
|
### Secrets
|
|
136
210
|
|
|
137
|
-
|
|
138
|
-
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:
|
|
139
214
|
|
|
140
215
|
```bash
|
|
141
216
|
laneyard secret set MATCH_PASSWORD --project cartes-ios # reads the value from standard input
|
|
@@ -146,25 +221,182 @@ The value is never an argument: a command line ends up in `~/.zsh_history` and i
|
|
|
146
221
|
`ps`. Typing the command alone leaves you at a blank line — type or paste the value, then
|
|
147
222
|
`Ctrl-D`.
|
|
148
223
|
|
|
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.
|
|
252
|
+
|
|
149
253
|
A secret becomes an environment variable for every run of the project it belongs to. Without
|
|
150
254
|
`--project` it applies to every project; a project secret of the same name wins over a global
|
|
151
255
|
one. Secrets are kept out of the logs unless you pass `--no-mask`, and a masked value must be at
|
|
152
256
|
least four characters long — see below.
|
|
153
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
|
+
|
|
154
321
|
### Readiness
|
|
155
322
|
|
|
156
|
-
Every project has a Readiness tab:
|
|
157
|
-
|
|
323
|
+
Every project has a Readiness tab: what stands between it and a build that runs while nobody
|
|
324
|
+
watches. Only the checks that apply to the project are shown — an Android project is never asked
|
|
325
|
+
for an App Store Connect key, because one irrelevant warning teaches you to ignore the screen.
|
|
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
|
+
|
|
335
|
+
Always:
|
|
158
336
|
|
|
159
337
|
- **the repository** answers `git ls-remote` without asking for credentials — a run that meets a
|
|
160
338
|
password prompt does not fail, it waits;
|
|
161
339
|
- **dependencies** are installable: `bundle check` against your Gemfile, or the `fastlane` a run
|
|
162
340
|
would otherwise find on the PATH;
|
|
163
|
-
- **
|
|
164
|
-
|
|
341
|
+
- **no lane calls an action known to stop and ask** — `prompt`, `sigh`, `cert`, a writable
|
|
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.
|
|
359
|
+
|
|
360
|
+
On iOS:
|
|
361
|
+
|
|
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;
|
|
165
373
|
- **match** has its `MATCH_PASSWORD` stored and is called `readonly`, so it fetches certificates
|
|
166
|
-
instead of trying to create them
|
|
167
|
-
|
|
374
|
+
instead of trying to create them.
|
|
375
|
+
|
|
376
|
+
On Android:
|
|
377
|
+
|
|
378
|
+
- **the keystore** is reachable without a prompt: a lane handing `gradle` a `storeFile` needs a
|
|
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.
|
|
397
|
+
|
|
398
|
+
Like the iOS ones, the Android checks read **literal arguments only**. `gradle(storePassword:
|
|
399
|
+
ENV["PW"])` is reported as undetermined, never guessed at: a checklist that guesses gets believed.
|
|
168
400
|
|
|
169
401
|
They run when you open the tab or press refresh, never on their own: they shell out to git and to
|
|
170
402
|
bundler. The time of the last run is on screen, because a stale green tick is worse than a red
|
|
@@ -179,7 +411,9 @@ growing a second copy of its form.
|
|
|
179
411
|
green and `match(readonly: false)` is a warning, but `match(readonly: ENV["RO"])` has no value
|
|
180
412
|
until the lane runs, so it is reported as undetermined — `○`, with the reason — rather than
|
|
181
413
|
guessed either way. The same applies to anything a lane computes: a checklist that guesses gets
|
|
182
|
-
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.
|
|
183
417
|
|
|
184
418
|
### The Fastfile
|
|
185
419
|
|
|
@@ -204,20 +438,51 @@ Below the editor is what git makes of the workspace: the diff, a message field,
|
|
|
204
438
|
`push`. A commit stages exactly the files that changed and never `git add -A` — a build leaves
|
|
205
439
|
artifacts and reports scattered around, and none of them belong in your history.
|
|
206
440
|
|
|
441
|
+
### Removing a project
|
|
442
|
+
|
|
443
|
+
Every project has a Settings tab, and the one thing on it is removal. It takes the project's block
|
|
444
|
+
out of `config.yml` — through the YAML document, so your comments and your key order survive —
|
|
445
|
+
and stops showing it. It is confirmed by typing the project's name: it is the one destructive
|
|
446
|
+
action in Laneyard, and a dialogue you can click through is not a confirmation.
|
|
447
|
+
|
|
448
|
+
What it does *not* do is most of the point, because "delete" elsewhere usually means the opposite:
|
|
449
|
+
|
|
450
|
+
- **the runs stay.** Every build that project ever ran keeps its page, its log and its artifacts,
|
|
451
|
+
each still at its own address. Removing a project means stop showing it, not destroy its past;
|
|
452
|
+
- **the clone and the artifacts stay on disk.** Their paths are printed when it is done, so you
|
|
453
|
+
can remove them yourself. Nothing is deleted from a web page on one click;
|
|
454
|
+
- **the secrets stay in the vault**, encrypted and unreachable, and come back if you add the
|
|
455
|
+
project again under the same name;
|
|
456
|
+
- **the repository is untouched.** Its `laneyard.yml`, its Fastfile and its history are the
|
|
457
|
+
repository's, not Laneyard's.
|
|
458
|
+
|
|
459
|
+
Removal is refused while a run of that project is in flight — that run is using the workspace. A
|
|
460
|
+
run still waiting in the queue will not start: it ends as failed, saying its project is gone.
|
|
461
|
+
|
|
207
462
|
## Security
|
|
208
463
|
|
|
209
464
|
Read this before putting Laneyard on a network.
|
|
210
465
|
|
|
211
466
|
- **It is built for a local network, not the internet.** It listens on `0.0.0.0` so you can reach
|
|
212
|
-
it from your laptop, behind
|
|
467
|
+
it from your laptop, behind a password. Do not expose it publicly. If you need remote access,
|
|
213
468
|
put it behind a VPN or an SSH tunnel.
|
|
214
|
-
- **
|
|
215
|
-
|
|
469
|
+
- **Passwords** are stored as scrypt hashes and repeated failures are throttled, per account, so
|
|
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.
|
|
473
|
+
- **A role is enforced by the server, not by the interface.** One table names the routes that
|
|
474
|
+
require an admin, and one hook is the only thing that reads it — there is no permission check
|
|
475
|
+
hidden inside a handler. What a builder is not shown is also what a builder is refused.
|
|
216
476
|
- **Secrets are encrypted at rest.** Values are stored with AES-256-GCM under a key kept in
|
|
217
477
|
`~/.laneyard/key` — outside the database, mode `600`, and Laneyard refuses to start if anyone
|
|
218
478
|
else can read it. Someone who walks off with `laneyard.db` gets ciphertext. Nothing else in the
|
|
219
479
|
process holds plaintext: the store, the API and the interface deal in names only, and no route
|
|
220
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.
|
|
221
486
|
- **Masked values are removed from output before it is written, not when it is displayed.** The
|
|
222
487
|
substitution happens once, at the point where a run's output fans out, so the log file on disk,
|
|
223
488
|
the live stream to your browser and the stored error summary all contain `••••••` and never the
|
|
@@ -237,6 +502,12 @@ What this does *not* cover, stated plainly:
|
|
|
237
502
|
pretending. Store it unmasked if it genuinely does not matter.
|
|
238
503
|
- **Anything fastlane prints that is not a stored secret is stored in the clear**, under
|
|
239
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.
|
|
240
511
|
|
|
241
512
|
## Status
|
|
242
513
|
|
|
@@ -249,6 +520,10 @@ What this does *not* cover, stated plainly:
|
|
|
249
520
|
- `✓` build queue, cancellation, timeouts surfaced in the UI
|
|
250
521
|
- `✓` a checklist that gets a project running unattended
|
|
251
522
|
- `✓` edit the Fastfile in the browser, verified on every save
|
|
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
|
|
525
|
+
- `✓` remove a project from the interface, without touching its history
|
|
526
|
+
- `✓` named accounts, with a builder role that never sees a credential
|
|
252
527
|
- `○` git-triggered and scheduled builds
|
|
253
528
|
|
|
254
529
|
Two things worth knowing today: listing lanes does not fetch the repository, so a lane you have
|
package/dist/src/cli/detect.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { access } from "node:fs/promises";
|
|
2
|
+
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, searchDir } from "../heuristics/platforms.js";
|
|
6
7
|
const exec = promisify(execFile);
|
|
7
8
|
const exists = async (p) => {
|
|
8
9
|
try {
|
|
@@ -30,14 +31,44 @@ function slugify(name) {
|
|
|
30
31
|
.replace(/^-+|-+$/g, "");
|
|
31
32
|
return s === "" ? "project" : s;
|
|
32
33
|
}
|
|
34
|
+
/** `git@host:owner/name.git` or `https://host/owner/name.git` → `name`. */
|
|
35
|
+
function repositoryName(url) {
|
|
36
|
+
if (!url)
|
|
37
|
+
return null;
|
|
38
|
+
const last = url.replace(/\/+$/, "").split(/[/:]/).pop();
|
|
39
|
+
if (!last)
|
|
40
|
+
return null;
|
|
41
|
+
const name = last.replace(/\.git$/, "");
|
|
42
|
+
return name === "" ? null : name;
|
|
43
|
+
}
|
|
44
|
+
/** The globbing `heuristics/platforms.ts` asks for, bound to one directory. */
|
|
45
|
+
const findIn = (dir) => (globs, { onlyDirectories }) => glob(globs, onlyDirectories ? { cwd: dir, onlyDirectories: true } : { cwd: dir, onlyFiles: true });
|
|
46
|
+
/** Turns an absolute path into a repository-relative one, with forward slashes. */
|
|
47
|
+
const toRepoPath = (root, absolute) => relative(root, absolute).split(sep).join("/");
|
|
33
48
|
/**
|
|
34
49
|
* Inspects an existing project and proposes a configuration.
|
|
35
50
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
51
|
+
* **Every path it reports is relative to the repository root, not to the
|
|
52
|
+
* directory the command was run in.** That distinction is the whole difficulty:
|
|
53
|
+
* Laneyard clones the repository, so a Fastfile at `app/fastlane` is at
|
|
54
|
+
* `app/fastlane` in the workspace no matter which folder someone happened to
|
|
55
|
+
* be standing in when they ran `laneyard setup`. Measuring from the current
|
|
56
|
+
* directory produced a configuration that looked right and pointed nowhere.
|
|
57
|
+
*
|
|
58
|
+
* Decides nothing irreversible: everything it returns is a proposal the user
|
|
59
|
+
* sees and can correct before it's written.
|
|
38
60
|
*/
|
|
39
61
|
export async function detectProject(dir) {
|
|
40
|
-
//
|
|
62
|
+
// Without a repository there is nothing to clone; `runSetupCommand` refuses
|
|
63
|
+
// shortly after, so falling back to `dir` here only keeps this function total.
|
|
64
|
+
// Both sides are resolved before being compared: on macOS the temporary
|
|
65
|
+
// directory is a symlink, and git always answers with the real path — so a
|
|
66
|
+
// raw comparison yields a nonsense `../../private/...` relative path.
|
|
67
|
+
const here = await realpath(dir).catch(() => dir);
|
|
68
|
+
const root = await realpath((await gitOr(["rev-parse", "--show-toplevel"], dir, null)) ?? dir).catch(() => dir);
|
|
69
|
+
const subPath = toRepoPath(root, here);
|
|
70
|
+
// Look from where the user is standing — that is what they meant by "this
|
|
71
|
+
// project" — but report what is found relative to the repository root.
|
|
41
72
|
const fastfiles = await glob(["fastlane/Fastfile", "*/fastlane/Fastfile", "*/*/fastlane/Fastfile"], {
|
|
42
73
|
cwd: dir,
|
|
43
74
|
absolute: true,
|
|
@@ -45,26 +76,48 @@ export async function detectProject(dir) {
|
|
|
45
76
|
});
|
|
46
77
|
const fastfile = fastfiles.sort((a, b) => a.length - b.length)[0] ?? null;
|
|
47
78
|
const fastlaneDir = fastfile
|
|
48
|
-
?
|
|
79
|
+
? toRepoPath(root, await realpath(join(fastfile, "..")).catch(() => join(fastfile, "..")))
|
|
49
80
|
: null;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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)));
|
|
91
|
+
const isIos = platforms.includes("ios");
|
|
92
|
+
const isAndroid = platforms.includes("android");
|
|
93
|
+
// Artifact patterns are anchored to the sub-project too. In a monorepo an
|
|
94
|
+
// unanchored `**/*.ipa` would collect a sibling app's build as if it were
|
|
95
|
+
// this one's — and nothing downstream would notice.
|
|
96
|
+
const prefix = subPath === "" ? "" : `${subPath}/`;
|
|
56
97
|
const artifactGlobs = [];
|
|
57
98
|
if (isIos)
|
|
58
|
-
artifactGlobs.push(
|
|
99
|
+
artifactGlobs.push(`${prefix}**/*.ipa`, `${prefix}**/*.app.dSYM.zip`);
|
|
59
100
|
if (isAndroid)
|
|
60
|
-
artifactGlobs.push(
|
|
101
|
+
artifactGlobs.push(`${prefix}**/*.apk`, `${prefix}**/*.aab`);
|
|
102
|
+
const gitUrl = await gitOr(["remote", "get-url", "origin"], dir, null);
|
|
103
|
+
// The slug names the repository, and the sub-project when there is one: two
|
|
104
|
+
// apps in the same monorepo must not both want to be called `app`.
|
|
105
|
+
//
|
|
106
|
+
// The name comes from the remote rather than the local folder, because the
|
|
107
|
+
// folder is an accident of where someone cloned. `…/popotheque.git` checked
|
|
108
|
+
// out into `~/work/current` should still be called `popotheque`.
|
|
109
|
+
const repoName = repositoryName(gitUrl) ?? basename(root);
|
|
110
|
+
const slug = slugify(subPath === "" ? repoName : `${repoName}-${subPath}`);
|
|
61
111
|
return {
|
|
62
|
-
slug
|
|
63
|
-
gitUrl
|
|
112
|
+
slug,
|
|
113
|
+
gitUrl,
|
|
64
114
|
defaultBranch: (await gitOr(["rev-parse", "--abbrev-ref", "HEAD"], dir, "main")) ?? "main",
|
|
65
115
|
fastlaneDir,
|
|
116
|
+
// A Gemfile beside the Fastfile is the one fastlane will use, not one at the
|
|
117
|
+
// repository root — `bundle exec` runs from the sub-project.
|
|
66
118
|
runtime: (await exists(join(dir, "Gemfile"))) ? "bundle" : "system",
|
|
67
119
|
artifactGlobs,
|
|
68
|
-
|
|
120
|
+
platforms,
|
|
121
|
+
subPath,
|
|
69
122
|
};
|
|
70
123
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
}
|
|
12
|
+
/** Reads from the real terminal. */
|
|
13
|
+
export function terminalAsker() {
|
|
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
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
async ask(label, proposed, hint) {
|
|
32
|
+
if (hint)
|
|
33
|
+
process.stdout.write(`\n ${hint}\n`);
|
|
34
|
+
// The proposal is shown in the prompt rather than typed for the user:
|
|
35
|
+
// pressing Return accepts it, which is what someone does nine times out
|
|
36
|
+
// of ten, and correcting it costs one line.
|
|
37
|
+
const answer = (await question(` ${label} [${proposed}]: `)).trim();
|
|
38
|
+
return answer === "" ? proposed : answer;
|
|
39
|
+
},
|
|
40
|
+
async confirm(question_, defaultYes) {
|
|
41
|
+
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
|
|
42
|
+
const answer = (await question(`${question_} ${suffix} `)).trim().toLowerCase();
|
|
43
|
+
if (answer === "")
|
|
44
|
+
return defaultYes;
|
|
45
|
+
return answer.startsWith("y");
|
|
46
|
+
},
|
|
47
|
+
close() {
|
|
48
|
+
rl.close();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Accepts every proposal without asking. Used by `--yes` and by the tests. */
|
|
53
|
+
export const acceptingAsker = {
|
|
54
|
+
async ask(_label, proposed) {
|
|
55
|
+
return proposed;
|
|
56
|
+
},
|
|
57
|
+
async confirm(_question, defaultYes) {
|
|
58
|
+
return defaultYes;
|
|
59
|
+
},
|
|
60
|
+
close() { },
|
|
61
|
+
};
|