laneyard 0.3.0 → 0.4.1
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 +227 -20
- 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/cli/uninstall.js +390 -0
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +100 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +53 -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 +23 -2
- 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 +137 -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/projects.js +60 -2
- 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-9nLYwtg7.js} +1 -1
- package/dist/web/assets/index-DsjxZtsO.css +1 -0
- package/dist/web/assets/index-pzPa9EZr.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/dist/src/main.js
CHANGED
|
@@ -5,20 +5,23 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { bold, dim, field, heading } from "./cli/style.js";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { PromptAborted } from "./cli/prompt.js";
|
|
8
9
|
import { runSetupCommand } from "./cli/setup.js";
|
|
9
10
|
import { runSecretCommand } from "./cli/secret.js";
|
|
11
|
+
import { runUninstallCommand } from "./cli/uninstall.js";
|
|
10
12
|
import { runUserCommand } from "./cli/user.js";
|
|
11
13
|
import { ConfigStore } from "./config/store.js";
|
|
12
14
|
import { CacheStore } from "./db/cache.js";
|
|
13
15
|
import { openDatabase } from "./db/open.js";
|
|
14
16
|
import { RunStore } from "./db/runs.js";
|
|
17
|
+
import { CredentialStore } from "./db/credentials.js";
|
|
15
18
|
import { SecretStore } from "./db/secrets.js";
|
|
16
19
|
import { buildApp } from "./server/app.js";
|
|
17
20
|
import { Vault } from "./secrets/vault.js";
|
|
18
21
|
import { makeInvoke } from "./sidecar/bridge.js";
|
|
19
22
|
import { LaneReader } from "./sidecar/lanes.js";
|
|
20
23
|
import { UsesReader } from "./sidecar/uses.js";
|
|
21
|
-
export const version = "0.
|
|
24
|
+
export const version = "0.4.1";
|
|
22
25
|
/** Assembles the server from a data folder. */
|
|
23
26
|
export async function createServerFromConfig(root) {
|
|
24
27
|
const config = new ConfigStore(join(root, "config.yml"));
|
|
@@ -30,7 +33,7 @@ export async function createServerFromConfig(root) {
|
|
|
30
33
|
// it. Queued runs never began, so they stay queued for the next start.
|
|
31
34
|
new RunStore(db).interruptInFlight();
|
|
32
35
|
const cache = new CacheStore(db);
|
|
33
|
-
const vault = await Vault.open(root, new SecretStore(db));
|
|
36
|
+
const vault = await Vault.open(root, new SecretStore(db), new CredentialStore(db));
|
|
34
37
|
const app = await buildApp({
|
|
35
38
|
config,
|
|
36
39
|
db,
|
|
@@ -109,6 +112,8 @@ const USAGE = `laneyard — a self-hosted web UI for fastlane
|
|
|
109
112
|
store a secret, its value read from standard input
|
|
110
113
|
laneyard user add NAME [--role admin|builder]
|
|
111
114
|
create an account, its password read from standard input
|
|
115
|
+
laneyard uninstall remove Laneyard's data folder, after showing what is in it
|
|
116
|
+
--dry-run shows the inventory and stops
|
|
112
117
|
laneyard start the server
|
|
113
118
|
laneyard --version print the version
|
|
114
119
|
|
|
@@ -150,6 +155,13 @@ if (invokedDirectly()) {
|
|
|
150
155
|
process.exit(await runSetupCommand(process.cwd(), join(home, "config.yml"), { slug, yes }));
|
|
151
156
|
}
|
|
152
157
|
catch (cause) {
|
|
158
|
+
// Ctrl-C in the middle of the questions. Nothing is written before the
|
|
159
|
+
// last confirmation, so the only thing worth saying is that it is safe to
|
|
160
|
+
// start over. 130 is what a shell expects from a command killed by SIGINT.
|
|
161
|
+
if (cause instanceof PromptAborted) {
|
|
162
|
+
process.stdout.write(`\n${dim("Setup interrupted — nothing was written. Run `laneyard setup` again.")}\n`);
|
|
163
|
+
process.exit(130);
|
|
164
|
+
}
|
|
153
165
|
// A taken slug or an unreadable file are ordinary situations. A stack
|
|
154
166
|
// trace is not an error message; it just suggests the tool is broken.
|
|
155
167
|
process.stderr.write(`${cause.message}\n`);
|
|
@@ -177,6 +189,15 @@ if (invokedDirectly()) {
|
|
|
177
189
|
err: (text) => process.stderr.write(text),
|
|
178
190
|
}));
|
|
179
191
|
}
|
|
192
|
+
// No `mkdir` here, unlike the commands above: this one asks what is there
|
|
193
|
+
// and would look ridiculous creating the folder it is about to report on.
|
|
194
|
+
if (command === "uninstall") {
|
|
195
|
+
process.exit(await runUninstallCommand(homeDir(), rest, {
|
|
196
|
+
stdin: process.stdin,
|
|
197
|
+
out: (text) => process.stdout.write(text),
|
|
198
|
+
err: (text) => process.stderr.write(text),
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
180
201
|
// 0.1.0 shipped this as `add`. Anyone who learned that name deserves a
|
|
181
202
|
// sentence rather than "Unknown command".
|
|
182
203
|
if (command === "add") {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
|
|
3
|
+
import { findAndroidBuild } from "../heuristics/android-root.js";
|
|
4
|
+
/**
|
|
5
|
+
* The file the build already asks for, supplied rather than demanded.
|
|
6
|
+
*
|
|
7
|
+
* `heuristics/android-signing.ts` describes the trap: the Flutter documentation
|
|
8
|
+
* ships a release build that falls back to the debug signing config when
|
|
9
|
+
* `key.properties` is absent, and gitignores that file. On a build server it is
|
|
10
|
+
* therefore always absent, the release build succeeds, and the `.aab` is signed
|
|
11
|
+
* with the debug key.
|
|
12
|
+
*
|
|
13
|
+
* The obvious fix is to tell the user to rewrite their build script. Laneyard
|
|
14
|
+
* does not do that — the project never adapts to Laneyard. So it writes the file
|
|
15
|
+
* the script is already looking for, for the length of one run, and only where
|
|
16
|
+
* its absence would ship a debug-signed artifact.
|
|
17
|
+
*
|
|
18
|
+
* **The property names are a convention, not a reading.** `conditionalOn` gave
|
|
19
|
+
* the file's *name*, because that is what appears in the script; the keys inside
|
|
20
|
+
* it are read somewhere else entirely, usually through a `Properties` object
|
|
21
|
+
* indexed by string. The four defaults below are the Flutter documentation's,
|
|
22
|
+
* which is why they are right most of the time and never certain. A project
|
|
23
|
+
* reading `keystoreProperties["alias"]` says so in the block's `property_names`
|
|
24
|
+
* setting — asking at configuration time is allowed; requiring a repository
|
|
25
|
+
* change is not.
|
|
26
|
+
*
|
|
27
|
+
* This is the one credential written into the persistent clone, because Gradle
|
|
28
|
+
* resolves the path relative to the build rather than to anything a run owns.
|
|
29
|
+
* Three guards earn that:
|
|
30
|
+
*
|
|
31
|
+
* 1. The first line is a marker, and Laneyard removes a marked file at the end
|
|
32
|
+
* of the run and sweeps for one again at the start of the next, so a process
|
|
33
|
+
* killed mid-build cannot leave passwords in a working tree indefinitely.
|
|
34
|
+
* 2. A file without the marker is never written over and never removed. It is
|
|
35
|
+
* the user's own — possibly their real signing configuration — and clobbering
|
|
36
|
+
* it would be far worse than any warning Laneyard could print.
|
|
37
|
+
* 3. Readiness must not read this file back and report the project ready
|
|
38
|
+
* because of something Laneyard wrote. That is enforced elsewhere; nothing
|
|
39
|
+
* here may make it impossible, which is the other reason for the marker.
|
|
40
|
+
*/
|
|
41
|
+
export const LANEYARD_MARKER = "# written by laneyard, do not commit";
|
|
42
|
+
/**
|
|
43
|
+
* The keys the Flutter documentation uses, in the order `property_names` lists
|
|
44
|
+
* them. The setting is a plain comma-separated list because that is the form a
|
|
45
|
+
* user can correct in one keystroke: the field arrives pre-filled with exactly
|
|
46
|
+
* these four, and a project that reads `alias` edits the fourth one.
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_PROPERTY_NAMES = ["storeFile", "storePassword", "keyPassword", "keyAlias"];
|
|
49
|
+
/** Where each of those four names gets its value from. */
|
|
50
|
+
const SLOTS = ["storeFile", "store_password", "key_password", "key_alias"];
|
|
51
|
+
/**
|
|
52
|
+
* Where the file goes, or null when nobody can say.
|
|
53
|
+
*
|
|
54
|
+
* The configured path wins outright: it exists precisely because detection
|
|
55
|
+
* cannot always tell, and a setting the user corrected must not be second-
|
|
56
|
+
* guessed by the guess it corrected. It is read relative to the app root, the
|
|
57
|
+
* same root the build script was found under, and a path climbing out of it is
|
|
58
|
+
* refused rather than followed — the value came from a form, and a run must not
|
|
59
|
+
* be able to drop a file with passwords in it anywhere on the server.
|
|
60
|
+
*
|
|
61
|
+
* Otherwise the parser's answer decides, and `unknown` means the file is not
|
|
62
|
+
* written at all. Writing it in the likelier of two directories would be worse
|
|
63
|
+
* than writing nothing: the build would go on signing with the debug key while
|
|
64
|
+
* a file sat next to it looking like the problem had been dealt with.
|
|
65
|
+
*/
|
|
66
|
+
async function locate(root, fields) {
|
|
67
|
+
const build = await findAndroidBuild(root);
|
|
68
|
+
// The build makes no such bet — or there is no android build here at all — so
|
|
69
|
+
// nothing is at stake and nothing is written. Checked even when the path was
|
|
70
|
+
// configured by hand: the setting says where the file would go, never that it
|
|
71
|
+
// is wanted, and a project that has since fixed its build script should stop
|
|
72
|
+
// being written into without having to remember to clear a field.
|
|
73
|
+
if (!build || !build.facts.releaseCanUseDebugKey)
|
|
74
|
+
return null;
|
|
75
|
+
const configured = (fields["properties_path"] ?? "").trim();
|
|
76
|
+
if (configured !== "") {
|
|
77
|
+
const path = resolve(root, normalize(configured));
|
|
78
|
+
const inside = relative(root, path);
|
|
79
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside))
|
|
80
|
+
return null;
|
|
81
|
+
return path;
|
|
82
|
+
}
|
|
83
|
+
if (build.facts.conditionalOn === null)
|
|
84
|
+
return null;
|
|
85
|
+
const { name, scope } = build.facts.conditionalOn;
|
|
86
|
+
if (scope === "root")
|
|
87
|
+
return join(build.gradleRoot, name);
|
|
88
|
+
if (scope === "module")
|
|
89
|
+
return join(build.moduleDir, name);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** The first line of a file, or null when there is no file to read. */
|
|
93
|
+
async function firstLine(path) {
|
|
94
|
+
const text = await readFile(path, "utf8").catch(() => null);
|
|
95
|
+
return text === null ? null : (text.split("\n")[0] ?? "");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Java `.properties` escaping, applied to values only.
|
|
99
|
+
*
|
|
100
|
+
* A backslash is an escape character in that format, and the store path ends in
|
|
101
|
+
* a file name that came from an upload — so a value can genuinely contain one.
|
|
102
|
+
* Newlines are escaped rather than dropped: a password truncated at a line break
|
|
103
|
+
* would produce a file Gradle reads happily and signs wrongly with.
|
|
104
|
+
*/
|
|
105
|
+
function escapeValue(value) {
|
|
106
|
+
return value.replace(/\\/g, "\\\\").replace(/\r/g, "\\r").replace(/\n/g, "\\n");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The names to write, positionally overridden by the block's setting.
|
|
110
|
+
*
|
|
111
|
+
* Exported because the readiness check names them on screen: what Laneyard is
|
|
112
|
+
* going to write into someone's build is not something to leave implicit, and
|
|
113
|
+
* the check must read the same list the writer does rather than a copy of it.
|
|
114
|
+
*/
|
|
115
|
+
export function propertyNames(fields) {
|
|
116
|
+
const configured = (fields["property_names"] ?? "").split(",").map((n) => n.trim());
|
|
117
|
+
return DEFAULT_PROPERTY_NAMES.map((fallback, i) => configured[i] || fallback);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Writes the properties file, and returns its path — or null, having written
|
|
121
|
+
* nothing.
|
|
122
|
+
*
|
|
123
|
+
* Null is the ordinary outcome, and there are four ways to reach it: no keystore
|
|
124
|
+
* block applies, the build does not fall back to the debug key, nobody could say
|
|
125
|
+
* where the file goes, or a file is already there that Laneyard did not write.
|
|
126
|
+
* Only the last of those is worth a word to the user, and it is the checklist's
|
|
127
|
+
* word to say, not the runner's.
|
|
128
|
+
*/
|
|
129
|
+
export async function writeGradleProperties(root, keystore) {
|
|
130
|
+
if (!keystore)
|
|
131
|
+
return null;
|
|
132
|
+
const path = await locate(root, keystore.fields);
|
|
133
|
+
if (path === null)
|
|
134
|
+
return null;
|
|
135
|
+
// Guard two. Anything already there without the marker is the user's, and the
|
|
136
|
+
// build using it is the correct outcome — their real signing configuration
|
|
137
|
+
// beats the one Laneyard would have assembled.
|
|
138
|
+
const existing = await firstLine(path);
|
|
139
|
+
if (existing !== null && existing.trimEnd() !== LANEYARD_MARKER)
|
|
140
|
+
return null;
|
|
141
|
+
const names = propertyNames(keystore.fields);
|
|
142
|
+
const values = SLOTS.map((slot) => slot === "storeFile" ? keystore.storeFile : (keystore.fields[slot] ?? ""));
|
|
143
|
+
const body = names
|
|
144
|
+
.map((name, i) => `${name}=${escapeValue(values[i])}`)
|
|
145
|
+
.join("\n");
|
|
146
|
+
await mkdir(dirname(path), { recursive: true });
|
|
147
|
+
// The mode is set twice deliberately, as in `materialise.ts`: `writeFile`'s
|
|
148
|
+
// mode is masked by the process umask and ignored outright for a file that
|
|
149
|
+
// already exists, and this one holds a signing password.
|
|
150
|
+
await writeFile(path, `${LANEYARD_MARKER}\n${body}\n`, { mode: 0o600 });
|
|
151
|
+
await chmod(path, 0o600);
|
|
152
|
+
return path;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Removes a file this run wrote, and only such a file.
|
|
156
|
+
*
|
|
157
|
+
* Called from the run's `finally`, beside the removal of the run's secrets
|
|
158
|
+
* directory, so a keystore password does not outlive the build that needed it.
|
|
159
|
+
* Takes null so the caller can hand it whatever `writeGradleProperties`
|
|
160
|
+
* returned without a branch of its own.
|
|
161
|
+
*/
|
|
162
|
+
export async function removeGradleProperties(path) {
|
|
163
|
+
if (path === null)
|
|
164
|
+
return;
|
|
165
|
+
if ((await firstLine(path))?.trimEnd() !== LANEYARD_MARKER)
|
|
166
|
+
return;
|
|
167
|
+
await rm(path, { force: true }).catch(() => { });
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Removes a marked file left where this run would write one.
|
|
171
|
+
*
|
|
172
|
+
* A run killed between writing the file and reaching its `finally` — an
|
|
173
|
+
* unplugged server, a `kill -9`, a container that lost its node — leaves
|
|
174
|
+
* passwords in a working tree that is kept between runs. Nothing at that moment
|
|
175
|
+
* can clean up after itself, so the next run does it before doing anything else.
|
|
176
|
+
*
|
|
177
|
+
* Silent about everything: a workspace that was never cloned has nothing to
|
|
178
|
+
* sweep, and a sweep that failed must not be the reason a build did not start.
|
|
179
|
+
*/
|
|
180
|
+
export async function sweepGradleProperties(root, keystore) {
|
|
181
|
+
// Deliberately not conditional on there being a block. The leftover to remove
|
|
182
|
+
// is from a *previous* run, and a keystore deleted since then would otherwise
|
|
183
|
+
// make the file that run wrote permanent — the one arrangement in which
|
|
184
|
+
// nothing would ever come back to clean it up.
|
|
185
|
+
const path = await locate(root, keystore?.fields ?? {}).catch(() => null);
|
|
186
|
+
await removeGradleProperties(path).catch(() => { });
|
|
187
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { chmod, mkdir, rm, rmdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { defaultVarNames } from "../credentials/kinds.js";
|
|
4
|
+
/**
|
|
5
|
+
* Turns the stored signing blocks into real files, for the length of one run.
|
|
6
|
+
*
|
|
7
|
+
* A keystore has no string form. Gradle's `storeFile` is a path, `sign_apk`
|
|
8
|
+
* wants a path, `app_store_connect_api_key` wants a path — so a block that
|
|
9
|
+
* exists only as ciphertext in SQLite cannot be used by anything, and something
|
|
10
|
+
* has to put bytes on a disk. This is that something, and the files it writes
|
|
11
|
+
* live exactly as long as the run does.
|
|
12
|
+
*
|
|
13
|
+
* The variable names are the project's, never Laneyard's. A Fastfile written
|
|
14
|
+
* around `ENV["ASC_KEY_FILEPATH"]` is not a Fastfile doing it wrong, and asking
|
|
15
|
+
* it to rename anything would make Laneyard a thing projects adapt to. So the
|
|
16
|
+
* name stored with the block is the only name exported: no default is emitted
|
|
17
|
+
* alongside it as a courtesy, because that courtesy is what makes a typo in the
|
|
18
|
+
* configured name look like it worked.
|
|
19
|
+
*
|
|
20
|
+
* **Every applicable block is materialised, whether or not the lane looks like
|
|
21
|
+
* it needs it.** Narrowing this to what a lane appears to use is tempting: it
|
|
22
|
+
* would shrink the window a private key spends unencrypted on disk, which is
|
|
23
|
+
* the one cost this module carries. It is not worth it. Detection reads a
|
|
24
|
+
* Fastfile that can call anything through `sh`, a plugin, or a lane in another
|
|
25
|
+
* file; a detector that guesses "not needed" and guesses wrong turns a build
|
|
26
|
+
* that worked into a build that fails — or worse, into a debug-signed artifact
|
|
27
|
+
* that ships. Detection decides what Laneyard *asks* for, never what it
|
|
28
|
+
* withholds at run time.
|
|
29
|
+
*
|
|
30
|
+
* A block that will not decrypt throws, and the run never starts. That is the
|
|
31
|
+
* same trade `Vault.resolveCredential` makes and for the same reason: an
|
|
32
|
+
* absent signing key is not a missing variable fastlane will name for you, it
|
|
33
|
+
* is an artifact that builds, uploads, and is rejected by the store days later.
|
|
34
|
+
*/
|
|
35
|
+
export async function materialiseCredentials(vault, projectSlug, runSecretsDir) {
|
|
36
|
+
const env = {};
|
|
37
|
+
let keystore;
|
|
38
|
+
const cleanup = async () => {
|
|
39
|
+
await rm(runSecretsDir, { recursive: true, force: true });
|
|
40
|
+
// And the `runs/<run id>` folder that held it, so a server does not
|
|
41
|
+
// accumulate one empty directory per build it has ever run. `rmdir` rather
|
|
42
|
+
// than `rm -r`: it refuses a folder that still has something in it, which
|
|
43
|
+
// is exactly the protection wanted for a path this function did not create.
|
|
44
|
+
await rmdir(dirname(runSecretsDir)).catch(() => { });
|
|
45
|
+
};
|
|
46
|
+
// The mode is applied explicitly rather than trusted to `mkdir`: the mode
|
|
47
|
+
// argument is masked by the process umask, and a server started from a shell
|
|
48
|
+
// with a lax umask would otherwise hand out a world-readable key directory.
|
|
49
|
+
await mkdir(runSecretsDir, { recursive: true });
|
|
50
|
+
await chmod(runSecretsDir, 0o700);
|
|
51
|
+
try {
|
|
52
|
+
for (const summary of vault.listCredentials(projectSlug)) {
|
|
53
|
+
const block = vault.resolveCredential(projectSlug, summary.kind);
|
|
54
|
+
if (!block)
|
|
55
|
+
continue;
|
|
56
|
+
const defaults = defaultVarNames(summary.kind);
|
|
57
|
+
const names = { ...defaults, ...block.varNames };
|
|
58
|
+
// One directory per kind, so the original file name survives intact.
|
|
59
|
+
// Some tools read meaning from it — `AuthKey_<KEY ID>.p8` is a convention
|
|
60
|
+
// fastlane's own docs use — and flattening two blocks into one directory
|
|
61
|
+
// would let a `.p8` and a service account JSON that happen to share a name
|
|
62
|
+
// overwrite each other, silently, on the run that used both.
|
|
63
|
+
const dir = join(runSecretsDir, summary.kind);
|
|
64
|
+
await mkdir(dir, { recursive: true });
|
|
65
|
+
await chmod(dir, 0o700);
|
|
66
|
+
// `basename` because the name came from an upload and has never been
|
|
67
|
+
// constrained: a stored `../../id_rsa` must land in this directory, not
|
|
68
|
+
// over something in the user's home.
|
|
69
|
+
const path = join(dir, basename(block.fileName) || summary.kind);
|
|
70
|
+
await writeFile(path, block.fileBytes, { mode: 0o600 });
|
|
71
|
+
await chmod(path, 0o600);
|
|
72
|
+
if (summary.kind === "android_keystore")
|
|
73
|
+
keystore = { storeFile: path, fields: block.fields };
|
|
74
|
+
env[names["path"] ?? defaults["path"]] = path;
|
|
75
|
+
for (const [field, value] of Object.entries(block.fields)) {
|
|
76
|
+
const name = names[field];
|
|
77
|
+
// A field with no name is a block stored before that field existed;
|
|
78
|
+
// dropping it beats exporting it under a name nobody agreed on.
|
|
79
|
+
if (name && value)
|
|
80
|
+
env[name] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
// Nothing may survive a partial materialisation: the blocks that did get
|
|
86
|
+
// written are as sensitive as the one that failed, and the caller's
|
|
87
|
+
// `cleanup` is never reached when this function throws.
|
|
88
|
+
await cleanup();
|
|
89
|
+
throw cause;
|
|
90
|
+
}
|
|
91
|
+
return { env, keystore, cleanup };
|
|
92
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { Workspace } from "../git/workspace.js";
|
|
3
|
+
import { gitEnvFor, Workspace } from "../git/workspace.js";
|
|
4
4
|
import { summarizeFailure } from "../heuristics/error-summary.js";
|
|
5
|
+
import { appRootOf, searchDir } from "../heuristics/platforms.js";
|
|
5
6
|
import { Redactor, scrub } from "../logs/redact.js";
|
|
6
7
|
import { collectArtifacts } from "./artifacts.js";
|
|
8
|
+
import { removeGradleProperties, sweepGradleProperties, writeGradleProperties, } from "./gradle-properties.js";
|
|
7
9
|
import { LiveStepTracker } from "./live-steps.js";
|
|
8
10
|
import { startPty } from "./pty.js";
|
|
9
11
|
import { readReport } from "./report.js";
|
|
@@ -13,8 +15,37 @@ import { readReport } from "./report.js";
|
|
|
13
15
|
* Never throws: every error is converted into a documented `failed` run,
|
|
14
16
|
* because a run that disappears without a trace is the worst possible
|
|
15
17
|
* behaviour for a build server.
|
|
18
|
+
*
|
|
19
|
+
* The whole of it is wrapped so the signing blocks written for this run are
|
|
20
|
+
* removed on every way out — the successful return, the four early exits, the
|
|
21
|
+
* cancellations, and the exception this function promises not to raise but
|
|
22
|
+
* cannot rule out. A private key left on disk because a clone failed is a leak
|
|
23
|
+
* with no expiry date, and the only moment at which it is certainly safe to
|
|
24
|
+
* delete is the moment fastlane has stopped running.
|
|
25
|
+
*
|
|
26
|
+
* The gradle properties file is removed here too, and it is the one written
|
|
27
|
+
* somewhere this run does not own: the clone is kept between runs, so passwords
|
|
28
|
+
* left in it would sit in a working tree until someone noticed. The path is
|
|
29
|
+
* carried out through a holder rather than returned, because it is decided deep
|
|
30
|
+
* inside the run and has to be visible to a `finally` wrapped around all of it.
|
|
16
31
|
*/
|
|
17
32
|
export async function executeRun(opts) {
|
|
33
|
+
const wrote = { properties: null };
|
|
34
|
+
try {
|
|
35
|
+
return await execute(opts, wrote);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
// Guarded by the marker, so a file the user replaced mid-run survives.
|
|
39
|
+
await removeGradleProperties(wrote.properties).catch(() => { });
|
|
40
|
+
// Deliberately swallowed: by now the log writer is closed and the run's
|
|
41
|
+
// verdict is recorded, so there is nowhere left to report this without
|
|
42
|
+
// rewriting a finished run's outcome as a failure it did not have. A
|
|
43
|
+
// directory that resists `rm -rf` is a broken disk, not a broken build.
|
|
44
|
+
if (opts.cleanup)
|
|
45
|
+
await opts.cleanup().catch(() => { });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function execute(opts, wrote) {
|
|
18
49
|
const { runId, runs, logs } = opts;
|
|
19
50
|
const writer = await logs.open(runId);
|
|
20
51
|
const tracker = new LiveStepTracker();
|
|
@@ -79,6 +110,29 @@ export async function executeRun(opts) {
|
|
|
79
110
|
catch (cause) {
|
|
80
111
|
return fail(`Unreadable project settings: ${cause.message}`);
|
|
81
112
|
}
|
|
113
|
+
// The properties file gradle may be waiting for, in the clone rather than in
|
|
114
|
+
// this run's own directory — `gradle-properties.ts` says why, and why it is
|
|
115
|
+
// hedged about with a marker. It happens here, after the settings, because
|
|
116
|
+
// the app root it is written under is derived from `fastlane_dir`, and before
|
|
117
|
+
// the cancellation checkpoint, so that a run cancelled in this exact instant
|
|
118
|
+
// still leaves the workspace as it found it.
|
|
119
|
+
//
|
|
120
|
+
// The sweep first, and whether or not this project still has a keystore: a
|
|
121
|
+
// run killed mid-build leaves a file nothing was left running to remove, and
|
|
122
|
+
// the next run is the first moment anything can. Its failure is not reported
|
|
123
|
+
// — a leftover that resists removal is a broken disk, not a broken build, and
|
|
124
|
+
// saying so here would fail a run for a file it has not needed yet.
|
|
125
|
+
const androidRoot = searchDir(opts.workspacePath, appRootOf(settings.fastlane_dir));
|
|
126
|
+
await sweepGradleProperties(androidRoot, opts.androidKeystore).catch(() => { });
|
|
127
|
+
try {
|
|
128
|
+
wrote.properties = await writeGradleProperties(androidRoot, opts.androidKeystore);
|
|
129
|
+
}
|
|
130
|
+
catch (cause) {
|
|
131
|
+
// Failing the run rather than carrying on. Carrying on is what produces the
|
|
132
|
+
// artifact this whole module exists to prevent: a release build that
|
|
133
|
+
// succeeds, signed with the debug key, rejected by the store days later.
|
|
134
|
+
return fail(`Could not write the signing properties file: ${cause.message}`);
|
|
135
|
+
}
|
|
82
136
|
// Cancelled during preparation: fastlane never gets to start.
|
|
83
137
|
if (opts.signal?.aborted) {
|
|
84
138
|
return finishAs("cancelled", "Cancelled");
|
|
@@ -90,6 +144,19 @@ export async function executeRun(opts) {
|
|
|
90
144
|
// didn't have time to overwrite. Without this cleanup, a run that fails
|
|
91
145
|
// before even reaching fastlane would adopt the previous run's timeline.
|
|
92
146
|
await rm(reportPath, { force: true });
|
|
147
|
+
// The identity fallback is only set when the workspace has none of its own:
|
|
148
|
+
// a clone Laneyard made carries no `user.email`, so a lane running `git
|
|
149
|
+
// commit` fails with "Please tell me who you are" on any server whose global
|
|
150
|
+
// git configuration is empty. Where an identity does exist — the server's own,
|
|
151
|
+
// or one set on the repository — it is left to win, because these variables
|
|
152
|
+
// override configuration rather than backing it up.
|
|
153
|
+
const gitEnv = gitEnvFor(opts.gitAuth ?? { kind: "none" });
|
|
154
|
+
if ((await workspace.identity().catch(() => null)) === null) {
|
|
155
|
+
gitEnv["GIT_AUTHOR_NAME"] = "Laneyard";
|
|
156
|
+
gitEnv["GIT_AUTHOR_EMAIL"] = "laneyard@localhost";
|
|
157
|
+
gitEnv["GIT_COMMITTER_NAME"] = "Laneyard";
|
|
158
|
+
gitEnv["GIT_COMMITTER_EMAIL"] = "laneyard@localhost";
|
|
159
|
+
}
|
|
93
160
|
const { done } = startPty({
|
|
94
161
|
command: useBundle ? "bundle" : "fastlane",
|
|
95
162
|
args: useBundle
|
|
@@ -99,12 +166,27 @@ export async function executeRun(opts) {
|
|
|
99
166
|
env: {
|
|
100
167
|
...opts.env,
|
|
101
168
|
...(opts.secrets ?? {}),
|
|
169
|
+
// After the secrets: a block is the more deliberate of the two, and it is
|
|
170
|
+
// the only one whose path variable points at a file that actually exists
|
|
171
|
+
// right now. A stray secret of the same name would otherwise send gradle
|
|
172
|
+
// looking for a keystore at a path from a previous machine.
|
|
173
|
+
...(opts.credentialEnv ?? {}),
|
|
102
174
|
// Order matters: secrets come after opts.env so a stored secret wins over
|
|
103
175
|
// a variable that happens to exist in the server's own environment, and
|
|
104
|
-
// before these
|
|
176
|
+
// before these fixed variables so no secret can override CI.
|
|
105
177
|
CI: "true",
|
|
106
178
|
FASTLANE_SKIP_UPDATE_CHECK: "1",
|
|
107
179
|
FORCE_COLOR: "1",
|
|
180
|
+
// A lane may run git itself — bumping and pushing a build number is a
|
|
181
|
+
// reasonable thing for a Fastfile to do — and until now that `sh("git
|
|
182
|
+
// push")` inherited none of the care Laneyard takes with its own git
|
|
183
|
+
// calls. It got the worst failure available: a push needing a credential
|
|
184
|
+
// did not fail, it waited, and the run sat there until its timeout with
|
|
185
|
+
// nothing in the log to say what for.
|
|
186
|
+
//
|
|
187
|
+
// After the secrets, deliberately. These are not preferences: a stored
|
|
188
|
+
// `GIT_TERMINAL_PROMPT=1` would restore exactly the hang this removes.
|
|
189
|
+
...gitEnv,
|
|
108
190
|
},
|
|
109
191
|
onData: (chunk) => void emit(chunk),
|
|
110
192
|
timeoutMs: settings.timeout_minutes * 60_000,
|
|
@@ -112,6 +194,13 @@ export async function executeRun(opts) {
|
|
|
112
194
|
});
|
|
113
195
|
const outcome = await done;
|
|
114
196
|
await emitRest();
|
|
197
|
+
// The moment gradle has certainly stopped reading it, which is earlier than
|
|
198
|
+
// the `finally` and worth taking: the artifact collection below globs the
|
|
199
|
+
// workspace, and a project whose globs are broad enough would otherwise be
|
|
200
|
+
// offered its own signing passwords as a downloadable artifact. The `finally`
|
|
201
|
+
// still runs — this is a narrowing, not a replacement, and removing a file
|
|
202
|
+
// that is already gone costs nothing.
|
|
203
|
+
await removeGradleProperties(wrote.properties).catch(() => { });
|
|
115
204
|
// --- Timeline -------------------------------------------------------------
|
|
116
205
|
// Everything that follows is after-sales service: the timeline and the
|
|
117
206
|
// artifacts embellish a run that's already finished. A database that
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fieldsOf } from "../credentials/kinds.js";
|
|
1
2
|
import { scrub } from "../logs/redact.js";
|
|
2
3
|
import { decrypt, encrypt } from "./cipher.js";
|
|
3
4
|
import { loadOrCreateKey } from "./key.js";
|
|
@@ -11,12 +12,14 @@ import { loadOrCreateKey } from "./key.js";
|
|
|
11
12
|
export class Vault {
|
|
12
13
|
key;
|
|
13
14
|
store;
|
|
14
|
-
|
|
15
|
+
credentials;
|
|
16
|
+
constructor(key, store, credentials) {
|
|
15
17
|
this.key = key;
|
|
16
18
|
this.store = store;
|
|
19
|
+
this.credentials = credentials;
|
|
17
20
|
}
|
|
18
|
-
static async open(home, store) {
|
|
19
|
-
return new Vault(await loadOrCreateKey(home), store);
|
|
21
|
+
static async open(home, store, credentials) {
|
|
22
|
+
return new Vault(await loadOrCreateKey(home), store, credentials);
|
|
20
23
|
}
|
|
21
24
|
async set(projectSlug, key, value, masked) {
|
|
22
25
|
this.store.set(projectSlug, key, encrypt(value, this.key), masked);
|
|
@@ -30,6 +33,37 @@ export class Vault {
|
|
|
30
33
|
listGlobal() {
|
|
31
34
|
return this.store.listGlobal();
|
|
32
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* What the vault holds under one project's own name — secrets and signing
|
|
38
|
+
* blocks — and nothing global.
|
|
39
|
+
*
|
|
40
|
+
* Its own method rather than a filter over `list`, because the question is a
|
|
41
|
+
* different one: `list` is "what would a run of this project see", and this is
|
|
42
|
+
* "what would be left behind if the project went away". A global secret is in
|
|
43
|
+
* the first answer and must never be in the second.
|
|
44
|
+
*/
|
|
45
|
+
ownedBy(projectSlug) {
|
|
46
|
+
return {
|
|
47
|
+
secrets: this.store.listOwn(projectSlug),
|
|
48
|
+
credentials: this.credentials.listOwn(projectSlug),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Forgets everything stored under one project's name.
|
|
53
|
+
*
|
|
54
|
+
* Never called by removing a project: that route leaves the vault alone on
|
|
55
|
+
* purpose and says what stayed. This is the separate, deliberate act someone
|
|
56
|
+
* takes afterwards, once they have read what it is they are about to lose —
|
|
57
|
+
* a credential is the one thing here that cannot be looked at before it goes.
|
|
58
|
+
*
|
|
59
|
+
* Scoped by slug, so a global secret or a global signing block survives it.
|
|
60
|
+
*/
|
|
61
|
+
forget(projectSlug) {
|
|
62
|
+
return {
|
|
63
|
+
secrets: this.store.removeAllOwn(projectSlug),
|
|
64
|
+
credentials: this.credentials.removeAllOwn(projectSlug),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
33
67
|
/**
|
|
34
68
|
* Removes this project's secret values from a piece of text, in one shot.
|
|
35
69
|
*
|
|
@@ -58,11 +92,109 @@ export class Vault {
|
|
|
58
92
|
}
|
|
59
93
|
return out;
|
|
60
94
|
}
|
|
61
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* One value, in the clear — and only when it was never declared secret.
|
|
97
|
+
*
|
|
98
|
+
* The refusal is the point. This vault has been write-only since it was
|
|
99
|
+
* written: the server never sent a value back, so the interface had nothing to
|
|
100
|
+
* uncover and no browser ever held one. That is worth keeping for anything
|
|
101
|
+
* anyone called a secret.
|
|
102
|
+
*
|
|
103
|
+
* But not everything stored here is one. `APP_VERSION`, `SENTRY_ORG`, an
|
|
104
|
+
* issuer id — those are identifiers, and being unable to check what was stored
|
|
105
|
+
* makes an import something you have to take on faith. The line between the
|
|
106
|
+
* two already existed and is the user's own: `masked` is "keep this out of the
|
|
107
|
+
* logs". A value that carries it is never returned, whoever asks.
|
|
108
|
+
*
|
|
109
|
+
* Returns null for an unknown key, and throws for a masked one — a caller
|
|
110
|
+
* that forgot to check must fail loudly rather than leak.
|
|
111
|
+
*/
|
|
112
|
+
reveal(projectSlug, key) {
|
|
113
|
+
const row = this.store.find(projectSlug, key);
|
|
114
|
+
if (!row)
|
|
115
|
+
return null;
|
|
116
|
+
if (row.masked) {
|
|
117
|
+
throw new Error(`${key} is kept out of the logs, so its value is never sent back.`);
|
|
118
|
+
}
|
|
119
|
+
return decrypt(row.valueEnc, this.key);
|
|
120
|
+
}
|
|
121
|
+
/** Flips whether a value is kept out of the logs, leaving the value alone. */
|
|
122
|
+
setMasked(projectSlug, key, masked) {
|
|
123
|
+
return this.store.setMasked(projectSlug, key, masked);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Stores a signing block: the file, and the fields that make it usable.
|
|
127
|
+
*
|
|
128
|
+
* `cipher.ts` speaks strings and a `.jks` is bytes, so the file makes the trip
|
|
129
|
+
* as base64. The fields travel as one JSON object rather than one row each,
|
|
130
|
+
* because a keystore missing its alias is not a block with a gap in it — it is
|
|
131
|
+
* not a block.
|
|
132
|
+
*/
|
|
133
|
+
async setCredential(projectSlug, kind, block) {
|
|
134
|
+
this.credentials.set(projectSlug, kind, {
|
|
135
|
+
fileName: block.fileName,
|
|
136
|
+
fileEnc: encrypt(block.fileBytes.toString("base64"), this.key),
|
|
137
|
+
fieldsEnc: encrypt(JSON.stringify(block.fields), this.key),
|
|
138
|
+
varNames: block.varNames,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* One block that applies to a project, in the clear, or undefined if there is none.
|
|
143
|
+
*
|
|
144
|
+
* Unlike `resolve`, an unreadable row throws. The leniency there is earned: a
|
|
145
|
+
* missing variable makes fastlane stop and say which one. A block that quietly
|
|
146
|
+
* fails to decrypt costs a debug-signed artifact that builds, uploads, and is
|
|
147
|
+
* rejected by the store days later — by which point nobody is looking at this
|
|
148
|
+
* run's log. The kind is in the message because that is the part you need to
|
|
149
|
+
* know before you can act.
|
|
150
|
+
*/
|
|
151
|
+
resolveCredential(projectSlug, kind) {
|
|
152
|
+
const row = this.credentials.find(projectSlug, kind);
|
|
153
|
+
if (!row)
|
|
154
|
+
return undefined;
|
|
155
|
+
try {
|
|
156
|
+
return {
|
|
157
|
+
fileName: row.fileName,
|
|
158
|
+
fileBytes: Buffer.from(decrypt(row.fileEnc, this.key), "base64"),
|
|
159
|
+
fields: JSON.parse(decrypt(row.fieldsEnc, this.key)),
|
|
160
|
+
varNames: row.varNames,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
throw new Error(`The stored ${kind} block cannot be decrypted. Its encryption key changed or the row is damaged; upload the credential again.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
listCredentials(projectSlug) {
|
|
168
|
+
return this.credentials.list(projectSlug);
|
|
169
|
+
}
|
|
170
|
+
listGlobalCredentials() {
|
|
171
|
+
return this.credentials.listGlobal();
|
|
172
|
+
}
|
|
173
|
+
removeCredential(projectSlug, kind) {
|
|
174
|
+
return this.credentials.remove(projectSlug, kind);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The values a run's output must not contain.
|
|
178
|
+
*
|
|
179
|
+
* A block's secret fields belong here as much as a masked secret does: a
|
|
180
|
+
* keystore password reaches the build as an environment variable, and gradle
|
|
181
|
+
* is perfectly willing to echo one back on failure.
|
|
182
|
+
*/
|
|
62
183
|
maskedValues(projectSlug) {
|
|
63
184
|
const masked = this.store.maskedKeys(projectSlug);
|
|
64
|
-
|
|
185
|
+
const values = Object.entries(this.resolve(projectSlug))
|
|
65
186
|
.filter(([key]) => masked.has(key))
|
|
66
187
|
.map(([, value]) => value);
|
|
188
|
+
for (const summary of this.credentials.list(projectSlug)) {
|
|
189
|
+
const block = this.resolveCredential(projectSlug, summary.kind);
|
|
190
|
+
if (!block)
|
|
191
|
+
continue;
|
|
192
|
+
for (const field of fieldsOf(summary.kind)) {
|
|
193
|
+
const value = block.fields[field.name];
|
|
194
|
+
if (field.secret && value)
|
|
195
|
+
values.push(value);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return values;
|
|
67
199
|
}
|
|
68
200
|
}
|