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.
Files changed (46) hide show
  1. package/README.md +227 -20
  2. package/dist/src/cli/detect.js +12 -3
  3. package/dist/src/cli/prompt.js +28 -3
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +44 -7
  7. package/dist/src/cli/uninstall.js +390 -0
  8. package/dist/src/credentials/kinds.js +120 -0
  9. package/dist/src/db/credentials.js +100 -0
  10. package/dist/src/db/schema.sql +39 -0
  11. package/dist/src/db/secrets.js +53 -0
  12. package/dist/src/db/sessions.js +61 -0
  13. package/dist/src/git/workspace.js +32 -6
  14. package/dist/src/heuristics/android-root.js +49 -0
  15. package/dist/src/heuristics/android-signing.js +98 -0
  16. package/dist/src/heuristics/appfile.js +60 -0
  17. package/dist/src/heuristics/blocking-actions.js +2 -0
  18. package/dist/src/heuristics/env-example.js +36 -0
  19. package/dist/src/heuristics/platforms.js +69 -10
  20. package/dist/src/heuristics/readiness.js +554 -25
  21. package/dist/src/main.js +23 -2
  22. package/dist/src/runner/gradle-properties.js +187 -0
  23. package/dist/src/runner/materialise.js +92 -0
  24. package/dist/src/runner/orchestrate.js +91 -2
  25. package/dist/src/secrets/vault.js +137 -5
  26. package/dist/src/server/app.js +30 -3
  27. package/dist/src/server/auth.js +50 -10
  28. package/dist/src/server/permissions.js +2 -0
  29. package/dist/src/server/required-secrets.js +30 -0
  30. package/dist/src/server/routes/account.js +57 -0
  31. package/dist/src/server/routes/credentials.js +108 -0
  32. package/dist/src/server/routes/projects.js +60 -2
  33. package/dist/src/server/routes/readiness.js +162 -6
  34. package/dist/src/server/routes/secrets.js +101 -0
  35. package/dist/src/sidecar/bridge.js +21 -1
  36. package/dist/src/sidecar/fastlane-dir.js +23 -0
  37. package/dist/src/sidecar/lanes.js +6 -0
  38. package/dist/src/sidecar/uses.js +21 -5
  39. package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-9nLYwtg7.js} +1 -1
  40. package/dist/web/assets/index-DsjxZtsO.css +1 -0
  41. package/dist/web/assets/index-pzPa9EZr.js +62 -0
  42. package/dist/web/index.html +2 -2
  43. package/package.json +1 -1
  44. package/ruby/introspect.rb +122 -9
  45. package/dist/web/assets/index-De6sxx6G.css +0 -1
  46. 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.3.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 three fixed variables so no secret can override CI.
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
- constructor(key, store) {
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
- /** The values a run's output must not contain. */
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
- return Object.entries(this.resolve(projectSlug))
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
  }