laneyard 0.1.0 → 0.2.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 +120 -21
- package/dist/src/cli/secret.js +133 -0
- package/dist/src/config/schema.js +21 -3
- package/dist/src/db/cache.js +8 -8
- package/dist/src/db/open.js +15 -0
- package/dist/src/db/runs.js +51 -6
- package/dist/src/db/schema.sql +20 -2
- package/dist/src/db/secrets.js +64 -0
- package/dist/src/fastfile/store.js +53 -0
- package/dist/src/git/workspace.js +95 -5
- package/dist/src/heuristics/blocking-actions.js +45 -0
- package/dist/src/heuristics/readiness.js +193 -0
- package/dist/src/logs/redact.js +86 -0
- package/dist/src/main.js +32 -3
- package/dist/src/runner/orchestrate.js +60 -10
- package/dist/src/runner/pty.js +27 -15
- package/dist/src/runner/queue.js +114 -0
- package/dist/src/secrets/cipher.js +28 -0
- package/dist/src/secrets/key.js +40 -0
- package/dist/src/secrets/vault.js +68 -0
- package/dist/src/server/app.js +70 -0
- package/dist/src/server/routes/fastfile.js +131 -0
- package/dist/src/server/routes/projects.js +8 -1
- package/dist/src/server/routes/readiness.js +89 -0
- package/dist/src/server/routes/runs.js +27 -45
- package/dist/src/server/routes/secrets.js +51 -0
- package/dist/src/sidecar/lanes.js +2 -2
- package/dist/src/sidecar/uses.js +40 -0
- package/dist/web/assets/Editor-D5Q4uj4n.js +14 -0
- package/dist/web/assets/index-D9_EL8LA.js +62 -0
- package/dist/web/assets/index-ZUqTX6d1.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +13 -5
- package/ruby/introspect.rb +80 -0
- package/dist/tests/cli/add.test.js +0 -64
- package/dist/tests/cli/detect.test.js +0 -63
- package/dist/tests/config/load.test.js +0 -68
- package/dist/tests/config/resolve.test.js +0 -44
- package/dist/tests/config/store.test.js +0 -58
- package/dist/tests/db/runs.test.js +0 -54
- package/dist/tests/e2e/full-thread.test.js +0 -65
- package/dist/tests/fixtures/repos.js +0 -30
- package/dist/tests/git/workspace.test.js +0 -66
- package/dist/tests/heuristics/error-summary.test.js +0 -31
- package/dist/tests/logs/store.test.js +0 -38
- package/dist/tests/main.test.js +0 -27
- package/dist/tests/ruby/introspect.test.js +0 -59
- package/dist/tests/runner/artifacts.test.js +0 -49
- package/dist/tests/runner/live-steps.test.js +0 -35
- package/dist/tests/runner/orchestrate.test.js +0 -124
- package/dist/tests/runner/pty.test.js +0 -53
- package/dist/tests/runner/report.test.js +0 -91
- package/dist/tests/server/api.test.js +0 -132
- package/dist/tests/server/auth.test.js +0 -53
- package/dist/tests/server/ws.test.js +0 -43
- package/dist/tests/sidecar/lanes.test.js +0 -52
- package/dist/tests/sidecar/ruby-env.test.js +0 -25
- package/dist/tests/smoke.test.js +0 -7
- package/dist/web/assets/index-583x0xuo.css +0 -1
- package/dist/web/assets/index-BEpABKPS.js +0 -61
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ your signing keys, and nobody meters your minutes.
|
|
|
9
9
|
It replaces neither fastlane nor your Fastfile — it drives them. Your lanes stay exactly where
|
|
10
10
|
they are.
|
|
11
11
|
|
|
12
|
-

|
|
12
|
+

|
|
13
13
|
|
|
14
14
|
## Why
|
|
15
15
|
|
|
@@ -33,10 +33,11 @@ change here.
|
|
|
33
33
|
| cost per build | electricity | per minute | electricity |
|
|
34
34
|
| setup | one command | a signup form| a weekend |
|
|
35
35
|
| works offline | yes | no | yes |
|
|
36
|
-
| build queue across a team |
|
|
36
|
+
| build queue across a team | yes, serial | yes | yes |
|
|
37
37
|
| runs on pull requests | planned | yes | yes |
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Laneyard's queue is serial: runs are drained one at a time across every project, in the order
|
|
40
|
+
they were asked for. If your team needs parallel builds across a fleet today, use something else.
|
|
40
41
|
Laneyard is for one machine you control.
|
|
41
42
|
|
|
42
43
|
## Requirements
|
|
@@ -81,7 +82,7 @@ npm link # puts `laneyard` on your PATH
|
|
|
81
82
|
Open it from any machine on your network, sign in, and your lanes are listed — because Laneyard
|
|
82
83
|
asked your project's own fastlane for them.
|
|
83
84
|
|
|
84
|
-

|
|
85
|
+

|
|
85
86
|
|
|
86
87
|
## Configuration
|
|
87
88
|
|
|
@@ -95,7 +96,7 @@ server:
|
|
|
95
96
|
port: 7890
|
|
96
97
|
bind: 0.0.0.0
|
|
97
98
|
password_hash: "scrypt$…" # written by `laneyard add`
|
|
98
|
-
max_concurrent_runs: 1
|
|
99
|
+
max_concurrent_runs: 1 # only 1 is accepted, see below
|
|
99
100
|
retention: { runs: 50, artifact_days: 30 }
|
|
100
101
|
|
|
101
102
|
projects:
|
|
@@ -106,6 +107,11 @@ projects:
|
|
|
106
107
|
git_auth: { kind: ssh_key, ref: ~/.ssh/id_ed25519 }
|
|
107
108
|
```
|
|
108
109
|
|
|
110
|
+
`max_concurrent_runs` accepts `1` and nothing else. Runs are drained from a single queue, one at
|
|
111
|
+
a time across every project — parallel runs would need a working directory per run, which does
|
|
112
|
+
not exist yet. A larger number is refused when the file loads rather than silently ignored, so a
|
|
113
|
+
server is never configured for builds that never happen.
|
|
114
|
+
|
|
109
115
|
### `laneyard.yml` — optional, committed in your repository
|
|
110
116
|
|
|
111
117
|
Build behaviour belongs next to the code, so it can be versioned with it:
|
|
@@ -126,6 +132,78 @@ not touch can be configured entirely from `config.yml`.
|
|
|
126
132
|
Both files are watched: edit them by hand and Laneyard picks the change up. An invalid file is
|
|
127
133
|
reported and the last valid configuration stays live — a typo never takes the server down.
|
|
128
134
|
|
|
135
|
+
### Secrets
|
|
136
|
+
|
|
137
|
+
Credentials do not live in a file. They go into an encrypted vault, from the Secrets tab of a
|
|
138
|
+
project or from the command line:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
laneyard secret set MATCH_PASSWORD --project cartes-ios # reads the value from standard input
|
|
142
|
+
echo "$GITHUB_TOKEN" | laneyard secret set GITHUB_TOKEN # global, and out of your shell history
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The value is never an argument: a command line ends up in `~/.zsh_history` and in the output of
|
|
146
|
+
`ps`. Typing the command alone leaves you at a blank line — type or paste the value, then
|
|
147
|
+
`Ctrl-D`.
|
|
148
|
+
|
|
149
|
+
A secret becomes an environment variable for every run of the project it belongs to. Without
|
|
150
|
+
`--project` it applies to every project; a project secret of the same name wins over a global
|
|
151
|
+
one. Secrets are kept out of the logs unless you pass `--no-mask`, and a masked value must be at
|
|
152
|
+
least four characters long — see below.
|
|
153
|
+
|
|
154
|
+
### Readiness
|
|
155
|
+
|
|
156
|
+
Every project has a Readiness tab: five checks on what stands between it and a build that runs
|
|
157
|
+
while nobody watches.
|
|
158
|
+
|
|
159
|
+
- **the repository** answers `git ls-remote` without asking for credentials — a run that meets a
|
|
160
|
+
password prompt does not fail, it waits;
|
|
161
|
+
- **dependencies** are installable: `bundle check` against your Gemfile, or the `fastlane` a run
|
|
162
|
+
would otherwise find on the PATH;
|
|
163
|
+
- **App Store Connect** has an API key in the vault rather than a `FASTLANE_SESSION`, which
|
|
164
|
+
expires and takes the next night's build with it;
|
|
165
|
+
- **match** has its `MATCH_PASSWORD` stored and is called `readonly`, so it fetches certificates
|
|
166
|
+
instead of trying to create them;
|
|
167
|
+
- **no lane calls an action known to stop and ask** — `prompt`, `sigh`, a writable `match`.
|
|
168
|
+
|
|
169
|
+
They run when you open the tab or press refresh, never on their own: they shell out to git and to
|
|
170
|
+
bundler. The time of the last run is on screen, because a stale green tick is worse than a red
|
|
171
|
+
cross.
|
|
172
|
+
|
|
173
|
+
Nothing here blocks anything. A red check is never the reason a run cannot be started, and
|
|
174
|
+
Laneyard never edits a Fastfile to make its own checklist go green — each line explains, you
|
|
175
|
+
decide. Where the fix genuinely is one action, the line links to the Secrets tab instead of
|
|
176
|
+
growing a second copy of its form.
|
|
177
|
+
|
|
178
|
+
**What it cannot see.** The checklist reads *literal* arguments only. `match(readonly: true)` is
|
|
179
|
+
green and `match(readonly: false)` is a warning, but `match(readonly: ENV["RO"])` has no value
|
|
180
|
+
until the lane runs, so it is reported as undetermined — `○`, with the reason — rather than
|
|
181
|
+
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 not covered at all yet.
|
|
183
|
+
|
|
184
|
+
### The Fastfile
|
|
185
|
+
|
|
186
|
+
Every project has a Fastfile tab. **It is a text editor** — your file, in a box, with Ruby
|
|
187
|
+
syntax highlighting and nothing between you and it. The structured view described in the design
|
|
188
|
+
document, where lanes and actions are things you arrange rather than type, is still to come. This
|
|
189
|
+
is the honest first half of it, and it is useful on its own: fixing a lane at 2am should not
|
|
190
|
+
require an SSH session.
|
|
191
|
+
|
|
192
|
+
**Every write is verified.** Saving sends the file to the server, which writes it byte-for-byte —
|
|
193
|
+
no reformatting, no trailing-newline fixing, no reordering — then asks fastlane to parse it and
|
|
194
|
+
list its lanes. If that fails, the previous content is put back on disk before the request
|
|
195
|
+
answers, and the reason fastlane gave appears above the editor with your work still in the box.
|
|
196
|
+
A broken Fastfile never reaches a workspace a run might build from.
|
|
197
|
+
|
|
198
|
+
Saving is explicit, never automatic: verification is a Ruby subprocess, not a regular expression,
|
|
199
|
+
and an editor that ran it on every keystroke would be both slow and dangerous. `⌘S` is another
|
|
200
|
+
way to ask, not an autosave. Laneyard also refuses to write at all while a run of that project is
|
|
201
|
+
in flight — that run is reading the very file the write would replace.
|
|
202
|
+
|
|
203
|
+
Below the editor is what git makes of the workspace: the diff, a message field, `commit` and
|
|
204
|
+
`push`. A commit stages exactly the files that changed and never `git add -A` — a build leaves
|
|
205
|
+
artifacts and reports scattered around, and none of them belong in your history.
|
|
206
|
+
|
|
129
207
|
## Security
|
|
130
208
|
|
|
131
209
|
Read this before putting Laneyard on a network.
|
|
@@ -135,15 +213,30 @@ Read this before putting Laneyard on a network.
|
|
|
135
213
|
put it behind a VPN or an SSH tunnel.
|
|
136
214
|
- **The password** is stored as an scrypt hash and repeated failures are throttled. Sessions live
|
|
137
215
|
in memory and do not survive a restart.
|
|
138
|
-
- **
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
216
|
+
- **Secrets are encrypted at rest.** Values are stored with AES-256-GCM under a key kept in
|
|
217
|
+
`~/.laneyard/key` — outside the database, mode `600`, and Laneyard refuses to start if anyone
|
|
218
|
+
else can read it. Someone who walks off with `laneyard.db` gets ciphertext. Nothing else in the
|
|
219
|
+
process holds plaintext: the store, the API and the interface deal in names only, and no route
|
|
220
|
+
ever sends a value back — which is why the Secrets tab has no reveal button.
|
|
221
|
+
- **Masked values are removed from output before it is written, not when it is displayed.** The
|
|
222
|
+
substitution happens once, at the point where a run's output fans out, so the log file on disk,
|
|
223
|
+
the live stream to your browser and the stored error summary all contain `••••••` and never the
|
|
224
|
+
value. It survives being split across two chunks of terminal output.
|
|
225
|
+
- **Do not put secrets in `config.yml`.** It is a plain file with ordinary permissions. Use
|
|
226
|
+
`laneyard secret set` or the Secrets tab.
|
|
227
|
+
|
|
228
|
+
What this does *not* cover, stated plainly:
|
|
229
|
+
|
|
230
|
+
- **Git credentials are not in the vault.** `git_auth` points at an SSH key on disk by path;
|
|
231
|
+
token authentication is refused at load time rather than silently ignored, so a project cannot
|
|
232
|
+
be configured for something that never happens. Laneyard removes the configured repository URL
|
|
233
|
+
from its own git error messages — so a token embedded in an HTTPS URL does not leak that way —
|
|
234
|
+
but that is one string, not a vault.
|
|
235
|
+
- **A value shorter than four characters is refused, not protected.** Removing a two-character
|
|
236
|
+
string from a log would shred the output while hiding nothing, so Laneyard says no rather than
|
|
237
|
+
pretending. Store it unmasked if it genuinely does not matter.
|
|
238
|
+
- **Anything fastlane prints that is not a stored secret is stored in the clear**, under
|
|
239
|
+
`~/.laneyard/logs/`.
|
|
147
240
|
|
|
148
241
|
## Status
|
|
149
242
|
|
|
@@ -152,15 +245,21 @@ this blunt.
|
|
|
152
245
|
- `✓` declare a project, clone it, list its lanes, run one, watch it live, download the artifact
|
|
153
246
|
- `✓` configuration entirely in files you can version and back up
|
|
154
247
|
- `✓` adopt an existing fastlane project with one command
|
|
155
|
-
-
|
|
156
|
-
-
|
|
157
|
-
-
|
|
158
|
-
-
|
|
248
|
+
- `✓` encrypted secret vault and log redaction
|
|
249
|
+
- `✓` build queue, cancellation, timeouts surfaced in the UI
|
|
250
|
+
- `✓` a checklist that gets a project running unattended
|
|
251
|
+
- `✓` edit the Fastfile in the browser, verified on every save
|
|
159
252
|
- `○` git-triggered and scheduled builds
|
|
160
253
|
|
|
161
|
-
Two
|
|
162
|
-
|
|
163
|
-
|
|
254
|
+
Two things worth knowing today: listing lanes does not fetch the repository, so a lane you have
|
|
255
|
+
just pushed appears after the next run; and runs execute one at a time across all projects, so a
|
|
256
|
+
build triggered while another is going waits its turn rather than starting alongside it. A queued
|
|
257
|
+
run survives a restart of the server — it is still queued when it comes back up, and starts on
|
|
258
|
+
its own.
|
|
259
|
+
|
|
260
|
+
## Changelog
|
|
261
|
+
|
|
262
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
164
263
|
|
|
165
264
|
## Contributing
|
|
166
265
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ConfigStore } from "../config/store.js";
|
|
3
|
+
import { openDatabase } from "../db/open.js";
|
|
4
|
+
import { SecretStore } from "../db/secrets.js";
|
|
5
|
+
import { MIN_LENGTH as MIN_REDACTABLE } from "../logs/redact.js";
|
|
6
|
+
import { Vault } from "../secrets/vault.js";
|
|
7
|
+
/** Same rule as the API: what cannot become an environment variable is refused here too. */
|
|
8
|
+
const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9
|
+
export const SECRET_USAGE = `laneyard secret set <NAME> [--project <slug>] [--no-mask]
|
|
10
|
+
|
|
11
|
+
The value is read from standard input, never from an argument:
|
|
12
|
+
|
|
13
|
+
laneyard secret set MATCH_PASSWORD --project app
|
|
14
|
+
echo "$TOKEN" | laneyard secret set GITHUB_TOKEN
|
|
15
|
+
`;
|
|
16
|
+
/**
|
|
17
|
+
* Reads standard input whole.
|
|
18
|
+
*
|
|
19
|
+
* A single trailing newline is dropped: `echo "$TOKEN" |` adds one, and a secret
|
|
20
|
+
* that silently gained a `\n` would fail authentication somewhere far away from
|
|
21
|
+
* here, with nothing on screen to explain why.
|
|
22
|
+
*/
|
|
23
|
+
async function readValue(stdin) {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
for await (const chunk of stdin)
|
|
26
|
+
chunks.push(Buffer.from(chunk));
|
|
27
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
28
|
+
return text.replace(/\r?\n$/, "");
|
|
29
|
+
}
|
|
30
|
+
function parse(args) {
|
|
31
|
+
let key = null;
|
|
32
|
+
let project = null;
|
|
33
|
+
let masked = true;
|
|
34
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
35
|
+
const arg = args[i];
|
|
36
|
+
if (arg === "--project" || arg === "-p") {
|
|
37
|
+
const next = args[i + 1];
|
|
38
|
+
if (next === undefined || next.startsWith("-"))
|
|
39
|
+
return "--project needs a project slug.";
|
|
40
|
+
project = next;
|
|
41
|
+
i += 1;
|
|
42
|
+
}
|
|
43
|
+
else if (arg === "--no-mask") {
|
|
44
|
+
masked = false;
|
|
45
|
+
}
|
|
46
|
+
else if (arg.startsWith("-")) {
|
|
47
|
+
return `Unknown option: ${arg}`;
|
|
48
|
+
}
|
|
49
|
+
else if (key === null) {
|
|
50
|
+
key = arg;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// A second bare argument is almost certainly the value typed on the command
|
|
54
|
+
// line — exactly what this command exists to prevent. Saying so is kinder
|
|
55
|
+
// than storing the name and ignoring the rest.
|
|
56
|
+
return "The value is read from standard input, not from the command line.";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (key === null)
|
|
60
|
+
return "Which secret? Give it a name.";
|
|
61
|
+
return { key, project, masked };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Entry point for `laneyard secret set`.
|
|
65
|
+
*
|
|
66
|
+
* Nothing it prints ever contains the value — not on success, not in an error.
|
|
67
|
+
* A terminal keeps scrollback and a shell keeps history; the point of reading
|
|
68
|
+
* from stdin would be lost if the value came straight back out.
|
|
69
|
+
*/
|
|
70
|
+
export async function runSecretCommand(home, args, io) {
|
|
71
|
+
const [subcommand, ...rest] = args;
|
|
72
|
+
if (subcommand !== "set") {
|
|
73
|
+
io.err(subcommand === undefined ? `${SECRET_USAGE}` : `Unknown subcommand: ${subcommand}\n\n${SECRET_USAGE}`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
const parsed = parse(rest);
|
|
77
|
+
if (typeof parsed === "string") {
|
|
78
|
+
io.err(`${parsed}\n\n${SECRET_USAGE}`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
if (!VALID_KEY.test(parsed.key)) {
|
|
82
|
+
io.err(`"${parsed.key}" is not a valid environment variable name: letters, digits and underscore, ` +
|
|
83
|
+
"not starting with a digit.\n");
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
// A slug typo would store a secret that no project ever sees, and the run
|
|
87
|
+
// would fail much later with fastlane complaining about a missing variable.
|
|
88
|
+
if (parsed.project !== null) {
|
|
89
|
+
const config = new ConfigStore(join(home, "config.yml"));
|
|
90
|
+
const loaded = await config.load();
|
|
91
|
+
if (!loaded.ok) {
|
|
92
|
+
io.err(`Unreadable configuration in ${join(home, "config.yml")}: ${loaded.error}\n`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
if (!config.project(parsed.project)) {
|
|
96
|
+
const known = config.projects().map((p) => p.slug);
|
|
97
|
+
io.err(`Unknown project: "${parsed.project}".` +
|
|
98
|
+
(known.length > 0 ? ` Known projects: ${known.join(", ")}.` : " No project is declared yet.") +
|
|
99
|
+
"\n");
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (io.interactive) {
|
|
104
|
+
io.err("The value is read from standard input, and standard input is a terminal.\n" +
|
|
105
|
+
"Pipe it in instead, so it stays out of your shell history:\n\n" +
|
|
106
|
+
` echo "$TOKEN" | laneyard secret set ${parsed.key}\n`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
const value = await readValue(io.stdin);
|
|
110
|
+
if (value === "") {
|
|
111
|
+
io.err("Nothing came in on standard input, so there is no value to store.\n");
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
// Same refusal as the API: accepting the request and quietly not redacting
|
|
115
|
+
// would leave someone believing they are protected.
|
|
116
|
+
if (parsed.masked && value.length < MIN_REDACTABLE) {
|
|
117
|
+
io.err(`A value kept out of the logs must be at least ${MIN_REDACTABLE} characters. ` +
|
|
118
|
+
"Shorter than that, removing it would shred the log without hiding anything. " +
|
|
119
|
+
"Pass --no-mask if you accept it appearing in the output.\n");
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const db = openDatabase(join(home, "laneyard.db"));
|
|
123
|
+
try {
|
|
124
|
+
const vault = await Vault.open(home, new SecretStore(db));
|
|
125
|
+
await vault.set(parsed.project, parsed.key, value, parsed.masked);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
db.close();
|
|
129
|
+
}
|
|
130
|
+
io.out(`✓ ${parsed.key} ${parsed.project === null ? "global" : `project ${parsed.project}`}` +
|
|
131
|
+
` ${parsed.masked ? "kept out of the logs" : "shown in the logs"}\n`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
@@ -27,8 +27,11 @@ export const projectEntrySchema = projectSettingsInputSchema.extend({
|
|
|
27
27
|
default_branch: z.string().default("main"),
|
|
28
28
|
git_auth: z
|
|
29
29
|
.object({
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// `token` is not accepted yet. The workspace only knows how to use an SSH
|
|
31
|
+
// key, so accepting it would leave a project configured for something that
|
|
32
|
+
// silently never happens — worse than a clear refusal at load time.
|
|
33
|
+
kind: z.enum(["none", "ssh_key"]),
|
|
34
|
+
/** Path to the SSH key file. */
|
|
32
35
|
ref: z.string().optional(),
|
|
33
36
|
})
|
|
34
37
|
.default({ kind: "none" }),
|
|
@@ -41,7 +44,22 @@ export const serverConfigSchema = z.object({
|
|
|
41
44
|
port: z.number().int().positive().default(7890),
|
|
42
45
|
bind: z.string().default("0.0.0.0"),
|
|
43
46
|
password_hash: z.string().min(1),
|
|
44
|
-
|
|
47
|
+
// Only 1 is accepted. Runs share one working directory per project, so a
|
|
48
|
+
// higher number would promise parallel builds that never happen — the
|
|
49
|
+
// queue drains one run at a time, whatever this says. Refusing at load
|
|
50
|
+
// time is the honest answer, as with `git_auth: token`.
|
|
51
|
+
max_concurrent_runs: z
|
|
52
|
+
.number()
|
|
53
|
+
.int()
|
|
54
|
+
.positive()
|
|
55
|
+
// Refined on the field itself, so zod reports `server.max_concurrent_runs`
|
|
56
|
+
// on its own: `load.ts` prefixes the message with that path, and an error
|
|
57
|
+
// saying `(root)` would leave the reader hunting for the field.
|
|
58
|
+
.refine((n) => n === 1, {
|
|
59
|
+
message: "only 1 is supported: runs are executed one at a time, and accepting more would " +
|
|
60
|
+
"promise parallel builds that never happen",
|
|
61
|
+
})
|
|
62
|
+
.default(1),
|
|
45
63
|
retention: z
|
|
46
64
|
.object({
|
|
47
65
|
runs: z.number().int().positive().default(50),
|
package/dist/src/db/cache.js
CHANGED
|
@@ -3,20 +3,20 @@ export class CacheStore {
|
|
|
3
3
|
constructor(db) {
|
|
4
4
|
this.db = db;
|
|
5
5
|
}
|
|
6
|
-
get(slug, hash) {
|
|
6
|
+
get(slug, kind, hash) {
|
|
7
7
|
const row = this.db
|
|
8
|
-
.prepare("SELECT payload FROM introspection_cache WHERE project_slug = ? AND config_hash = ?")
|
|
9
|
-
.get(slug, hash);
|
|
8
|
+
.prepare("SELECT payload FROM introspection_cache WHERE project_slug = ? AND kind = ? AND config_hash = ?")
|
|
9
|
+
.get(slug, kind, hash);
|
|
10
10
|
return row ? JSON.parse(row.payload) : null;
|
|
11
11
|
}
|
|
12
|
-
put(slug, hash, payload) {
|
|
12
|
+
put(slug, kind, hash, payload) {
|
|
13
13
|
this.db
|
|
14
|
-
.prepare(`INSERT INTO introspection_cache (project_slug, config_hash, payload, fetched_at)
|
|
15
|
-
VALUES (?, ?, ?, ?)
|
|
16
|
-
ON CONFLICT (project_slug) DO UPDATE
|
|
14
|
+
.prepare(`INSERT INTO introspection_cache (project_slug, kind, config_hash, payload, fetched_at)
|
|
15
|
+
VALUES (?, ?, ?, ?, ?)
|
|
16
|
+
ON CONFLICT (project_slug, kind) DO UPDATE
|
|
17
17
|
SET config_hash = excluded.config_hash,
|
|
18
18
|
payload = excluded.payload,
|
|
19
19
|
fetched_at = excluded.fetched_at`)
|
|
20
|
-
.run(slug, hash, JSON.stringify(payload), new Date().toISOString());
|
|
20
|
+
.run(slug, kind, hash, JSON.stringify(payload), new Date().toISOString());
|
|
21
21
|
}
|
|
22
22
|
}
|
package/dist/src/db/open.js
CHANGED
|
@@ -6,7 +6,22 @@ export function openDatabase(path) {
|
|
|
6
6
|
const db = new Database(path);
|
|
7
7
|
db.pragma("journal_mode = WAL");
|
|
8
8
|
db.pragma("foreign_keys = ON");
|
|
9
|
+
migrateIntrospectionCache(db);
|
|
9
10
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
db.exec(readFileSync(join(here, "schema.sql"), "utf8"));
|
|
11
12
|
return db;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Rebuilds the introspection cache when it predates the `kind` column.
|
|
16
|
+
*
|
|
17
|
+
* `CREATE TABLE IF NOT EXISTS` leaves an existing table alone, so a database
|
|
18
|
+
* written by 0.1.0 would keep the old single-key shape and collide. Dropping is
|
|
19
|
+
* the right answer here and only here: this table is a cache, and losing it
|
|
20
|
+
* costs one slow read.
|
|
21
|
+
*/
|
|
22
|
+
function migrateIntrospectionCache(db) {
|
|
23
|
+
const columns = db.prepare("PRAGMA table_info(introspection_cache)").all();
|
|
24
|
+
if (columns.length > 0 && !columns.some((c) => c.name === "kind")) {
|
|
25
|
+
db.exec("DROP TABLE introspection_cache");
|
|
26
|
+
}
|
|
27
|
+
}
|
package/dist/src/db/runs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
const
|
|
1
|
+
/** Statuses that mean a run has begun and cannot survive the process that started it. */
|
|
2
|
+
const IN_FLIGHT = ["preparing", "running"];
|
|
3
3
|
const toRun = (r) => ({
|
|
4
4
|
id: r.id,
|
|
5
5
|
projectSlug: r.project_slug,
|
|
@@ -52,12 +52,57 @@ export class RunStore {
|
|
|
52
52
|
.prepare("UPDATE run SET status = ?, finished_at = ?, exit_code = ?, error_summary = ? WHERE id = ?")
|
|
53
53
|
.run(r.status, now(), r.exitCode, r.errorSummary, id);
|
|
54
54
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
const
|
|
55
|
+
/** Runs waiting to start, oldest first. Insertion order is the queue. */
|
|
56
|
+
queued() {
|
|
57
|
+
const rows = this.db
|
|
58
|
+
.prepare("SELECT * FROM run WHERE status = 'queued' ORDER BY id")
|
|
59
|
+
.all();
|
|
60
|
+
return rows.map(toRun);
|
|
61
|
+
}
|
|
62
|
+
/** 1 for the next to start, null if the run is not waiting. */
|
|
63
|
+
queuePosition(id) {
|
|
64
|
+
const row = this.db
|
|
65
|
+
.prepare(`SELECT COUNT(*) AS ahead FROM run
|
|
66
|
+
WHERE status = 'queued' AND id <= ?
|
|
67
|
+
AND EXISTS (SELECT 1 FROM run r2 WHERE r2.id = ? AND r2.status = 'queued')`)
|
|
68
|
+
.get(id, id);
|
|
69
|
+
return row.ahead === 0 ? null : row.ahead;
|
|
70
|
+
}
|
|
71
|
+
/** How many runs have begun. The worker consults it before taking the next. */
|
|
72
|
+
activeCount() {
|
|
73
|
+
const placeholders = IN_FLIGHT.map(() => "?").join(", ");
|
|
74
|
+
const row = this.db
|
|
75
|
+
.prepare(`SELECT COUNT(*) AS n FROM run WHERE status IN (${placeholders})`)
|
|
76
|
+
.get(...IN_FLIGHT);
|
|
77
|
+
return row.n;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* True if this project has a run that has begun and not yet finished.
|
|
81
|
+
*
|
|
82
|
+
* Used to refuse a Fastfile write while it's true: that run is reading the
|
|
83
|
+
* very file the write would replace, the same reason `prepare` refuses to
|
|
84
|
+
* touch a dirty workspace.
|
|
85
|
+
*/
|
|
86
|
+
hasActiveRun(slug) {
|
|
87
|
+
const placeholders = IN_FLIGHT.map(() => "?").join(", ");
|
|
88
|
+
const row = this.db
|
|
89
|
+
.prepare(`SELECT COUNT(*) AS n FROM run WHERE project_slug = ? AND status IN (${placeholders})`)
|
|
90
|
+
.get(slug, ...IN_FLIGHT);
|
|
91
|
+
return row.n > 0;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Marks as interrupted every run that had begun, leaving queued ones alone.
|
|
95
|
+
*
|
|
96
|
+
* A run that started cannot survive the process that spawned it — its
|
|
97
|
+
* pseudo-terminal died with it. A queued run never began: it still means
|
|
98
|
+
* exactly what it meant, and dropping it would be a silent surprise for
|
|
99
|
+
* someone who queued three builds and restarted the server.
|
|
100
|
+
*/
|
|
101
|
+
interruptInFlight() {
|
|
102
|
+
const placeholders = IN_FLIGHT.map(() => "?").join(", ");
|
|
58
103
|
const res = this.db
|
|
59
104
|
.prepare(`UPDATE run SET status = 'interrupted', finished_at = ? WHERE status IN (${placeholders})`)
|
|
60
|
-
.run(now(), ...
|
|
105
|
+
.run(now(), ...IN_FLIGHT);
|
|
61
106
|
return res.changes;
|
|
62
107
|
}
|
|
63
108
|
replaceSteps(runId, steps) {
|
package/dist/src/db/schema.sql
CHANGED
|
@@ -37,9 +37,27 @@ CREATE TABLE IF NOT EXISTS artifact (
|
|
|
37
37
|
kind TEXT NOT NULL
|
|
38
38
|
);
|
|
39
39
|
|
|
40
|
+
-- `kind` is part of the key because more than one reader caches per project —
|
|
41
|
+
-- lanes and the actions each lane calls. Without it the second write overwrites
|
|
42
|
+
-- the first, and the next read returns a payload of the wrong shape rather than
|
|
43
|
+
-- missing the cache.
|
|
40
44
|
CREATE TABLE IF NOT EXISTS introspection_cache (
|
|
41
|
-
project_slug TEXT
|
|
45
|
+
project_slug TEXT NOT NULL,
|
|
46
|
+
kind TEXT NOT NULL,
|
|
42
47
|
config_hash TEXT NOT NULL,
|
|
43
48
|
payload TEXT NOT NULL,
|
|
44
|
-
fetched_at TEXT NOT NULL
|
|
49
|
+
fetched_at TEXT NOT NULL,
|
|
50
|
+
PRIMARY KEY (project_slug, kind)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
-- A global secret is stored with an empty project_slug rather than NULL:
|
|
54
|
+
-- SQLite considers two NULLs distinct in a UNIQUE index, so NULL would let the
|
|
55
|
+
-- same global name be inserted twice.
|
|
56
|
+
CREATE TABLE IF NOT EXISTS secret (
|
|
57
|
+
project_slug TEXT NOT NULL DEFAULT '',
|
|
58
|
+
key TEXT NOT NULL,
|
|
59
|
+
value_enc TEXT NOT NULL,
|
|
60
|
+
masked INTEGER NOT NULL DEFAULT 1,
|
|
61
|
+
updated_at TEXT NOT NULL,
|
|
62
|
+
PRIMARY KEY (project_slug, key)
|
|
45
63
|
);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const GLOBAL = "";
|
|
2
|
+
/**
|
|
3
|
+
* Stores encrypted secrets. Knows nothing about encryption itself: it takes and
|
|
4
|
+
* returns ciphertext, so a bug here cannot leak a plaintext value.
|
|
5
|
+
*
|
|
6
|
+
* A project secret shadows a global one of the same name — the same precedence
|
|
7
|
+
* as the configuration, and the least surprising rule.
|
|
8
|
+
*/
|
|
9
|
+
export class SecretStore {
|
|
10
|
+
db;
|
|
11
|
+
constructor(db) {
|
|
12
|
+
this.db = db;
|
|
13
|
+
}
|
|
14
|
+
set(projectSlug, key, valueEnc, masked) {
|
|
15
|
+
this.db
|
|
16
|
+
.prepare(`INSERT INTO secret (project_slug, key, value_enc, masked, updated_at)
|
|
17
|
+
VALUES (?, ?, ?, ?, ?)
|
|
18
|
+
ON CONFLICT (project_slug, key) DO UPDATE
|
|
19
|
+
SET value_enc = excluded.value_enc,
|
|
20
|
+
masked = excluded.masked,
|
|
21
|
+
updated_at = excluded.updated_at`)
|
|
22
|
+
.run(projectSlug ?? GLOBAL, key, valueEnc, masked ? 1 : 0, new Date().toISOString());
|
|
23
|
+
}
|
|
24
|
+
/** Rows that apply to a project, project scope winning over global. */
|
|
25
|
+
applicable(projectSlug) {
|
|
26
|
+
const rows = this.db
|
|
27
|
+
.prepare("SELECT * FROM secret WHERE project_slug IN (?, ?) ORDER BY key")
|
|
28
|
+
.all(projectSlug, GLOBAL);
|
|
29
|
+
const byKey = new Map();
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
const existing = byKey.get(row.key);
|
|
32
|
+
if (!existing || row.project_slug !== GLOBAL)
|
|
33
|
+
byKey.set(row.key, row);
|
|
34
|
+
}
|
|
35
|
+
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
36
|
+
}
|
|
37
|
+
list(projectSlug) {
|
|
38
|
+
return this.applicable(projectSlug).map((row) => ({
|
|
39
|
+
key: row.key,
|
|
40
|
+
masked: row.masked === 1,
|
|
41
|
+
scope: row.project_slug === GLOBAL ? "global" : "project",
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
listGlobal() {
|
|
45
|
+
const rows = this.db
|
|
46
|
+
.prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key")
|
|
47
|
+
.all(GLOBAL);
|
|
48
|
+
return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "global" }));
|
|
49
|
+
}
|
|
50
|
+
/** Ciphertext by name, for the vault to decrypt. */
|
|
51
|
+
encrypted(projectSlug) {
|
|
52
|
+
return Object.fromEntries(this.applicable(projectSlug).map((row) => [row.key, row.value_enc]));
|
|
53
|
+
}
|
|
54
|
+
/** Which of the applicable secrets should be kept out of the logs. */
|
|
55
|
+
maskedKeys(projectSlug) {
|
|
56
|
+
return new Set(this.applicable(projectSlug).filter((r) => r.masked === 1).map((r) => r.key));
|
|
57
|
+
}
|
|
58
|
+
remove(projectSlug, key) {
|
|
59
|
+
const res = this.db
|
|
60
|
+
.prepare("DELETE FROM secret WHERE project_slug = ? AND key = ?")
|
|
61
|
+
.run(projectSlug ?? GLOBAL, key);
|
|
62
|
+
return res.changes > 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
const DEFAULT_FASTLANE_DIR = "fastlane";
|
|
4
|
+
/**
|
|
5
|
+
* Reads and writes the Fastfile byte-for-byte.
|
|
6
|
+
*
|
|
7
|
+
* A file written by hand must never come back mangled: no reformatting, no
|
|
8
|
+
* trailing-newline fixing, no reordering. Someone may have spent a long time
|
|
9
|
+
* on that file, so `write` keeps the previous content only in memory — never
|
|
10
|
+
* in a sibling file, which would show up as an untracked file in the git
|
|
11
|
+
* workspace and eventually get committed by someone in a hurry — and puts it
|
|
12
|
+
* back the moment the injected verifier says the new content doesn't hold up.
|
|
13
|
+
*/
|
|
14
|
+
export class FastfileStore {
|
|
15
|
+
async read(workspacePath, fastlaneDir = DEFAULT_FASTLANE_DIR) {
|
|
16
|
+
return readFile(this.resolvePath(workspacePath, fastlaneDir), "utf8");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Writes `content` verbatim, then verifies it. On failure, the previous
|
|
20
|
+
* content is written back before returning, so the workspace never lands
|
|
21
|
+
* between the old file and the new one.
|
|
22
|
+
*/
|
|
23
|
+
async write(workspacePath, content, verify, fastlaneDir = DEFAULT_FASTLANE_DIR) {
|
|
24
|
+
const path = this.resolvePath(workspacePath, fastlaneDir);
|
|
25
|
+
const previous = await readFile(path, "utf8");
|
|
26
|
+
await writeFile(path, content, "utf8");
|
|
27
|
+
const result = await verify();
|
|
28
|
+
if (!result.ok) {
|
|
29
|
+
// Whatever broke, the file on disk goes back to exactly what it was
|
|
30
|
+
// before this call — a stray backup file would be a second thing to
|
|
31
|
+
// clean up and forget about.
|
|
32
|
+
await writeFile(path, previous, "utf8");
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolves the Fastfile's path, refusing to leave the workspace.
|
|
38
|
+
*
|
|
39
|
+
* `fastlaneDir` comes from configuration — the project's own
|
|
40
|
+
* `laneyard.yml`, or the server's `config.yml` — and a value like
|
|
41
|
+
* `../../etc` must not turn an editor into a way to write anywhere on the
|
|
42
|
+
* machine.
|
|
43
|
+
*/
|
|
44
|
+
resolvePath(workspacePath, fastlaneDir) {
|
|
45
|
+
const root = resolve(workspacePath);
|
|
46
|
+
const full = resolve(root, fastlaneDir, "Fastfile");
|
|
47
|
+
const rel = relative(root, full);
|
|
48
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
49
|
+
throw new Error(`fastlane_dir resolves outside the workspace: ${fastlaneDir}`);
|
|
50
|
+
}
|
|
51
|
+
return full;
|
|
52
|
+
}
|
|
53
|
+
}
|