laneyard 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +295 -20
  2. package/dist/src/cli/detect.js +69 -16
  3. package/dist/src/cli/prompt.js +61 -0
  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 +319 -0
  7. package/dist/src/cli/style.js +31 -0
  8. package/dist/src/cli/user.js +127 -0
  9. package/dist/src/config/accounts.js +163 -0
  10. package/dist/src/config/load.js +61 -1
  11. package/dist/src/config/schema.js +26 -1
  12. package/dist/src/config/store.js +10 -0
  13. package/dist/src/config/yaml.js +14 -0
  14. package/dist/src/credentials/kinds.js +120 -0
  15. package/dist/src/db/credentials.js +75 -0
  16. package/dist/src/db/schema.sql +39 -0
  17. package/dist/src/db/secrets.js +28 -0
  18. package/dist/src/db/sessions.js +61 -0
  19. package/dist/src/git/workspace.js +32 -6
  20. package/dist/src/heuristics/android-root.js +49 -0
  21. package/dist/src/heuristics/android-signing.js +98 -0
  22. package/dist/src/heuristics/appfile.js +60 -0
  23. package/dist/src/heuristics/blocking-actions.js +22 -0
  24. package/dist/src/heuristics/env-example.js +36 -0
  25. package/dist/src/heuristics/platforms.js +136 -0
  26. package/dist/src/heuristics/readiness.js +697 -25
  27. package/dist/src/main.js +58 -8
  28. package/dist/src/runner/gradle-properties.js +187 -0
  29. package/dist/src/runner/materialise.js +92 -0
  30. package/dist/src/runner/orchestrate.js +91 -2
  31. package/dist/src/secrets/vault.js +106 -5
  32. package/dist/src/server/app.js +94 -15
  33. package/dist/src/server/auth.js +127 -19
  34. package/dist/src/server/permissions.js +75 -0
  35. package/dist/src/server/required-secrets.js +30 -0
  36. package/dist/src/server/routes/account.js +57 -0
  37. package/dist/src/server/routes/credentials.js +108 -0
  38. package/dist/src/server/routes/projects.js +42 -0
  39. package/dist/src/server/routes/readiness.js +175 -6
  40. package/dist/src/server/routes/secrets.js +101 -0
  41. package/dist/src/server/routes/users.js +64 -0
  42. package/dist/src/sidecar/bridge.js +37 -1
  43. package/dist/src/sidecar/fastlane-dir.js +23 -0
  44. package/dist/src/sidecar/lanes.js +6 -0
  45. package/dist/src/sidecar/uses.js +21 -5
  46. package/dist/web/assets/{Editor-D5Q4uj4n.js → Editor-DynuBC2l.js} +1 -1
  47. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  48. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  49. package/dist/web/index.html +2 -2
  50. package/package.json +1 -1
  51. package/ruby/introspect.rb +122 -9
  52. package/dist/web/assets/index-D9_EL8LA.js +0 -62
  53. package/dist/web/assets/index-ZUqTX6d1.css +0 -1
package/dist/src/main.js CHANGED
@@ -3,20 +3,24 @@ import { realpathSync } from "node:fs";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { bold, dim, field, heading } from "./cli/style.js";
6
7
  import { fileURLToPath } from "node:url";
7
- import { runAddCommand } from "./cli/add.js";
8
+ import { PromptAborted } from "./cli/prompt.js";
9
+ import { runSetupCommand } from "./cli/setup.js";
8
10
  import { runSecretCommand } from "./cli/secret.js";
11
+ import { runUserCommand } from "./cli/user.js";
9
12
  import { ConfigStore } from "./config/store.js";
10
13
  import { CacheStore } from "./db/cache.js";
11
14
  import { openDatabase } from "./db/open.js";
12
15
  import { RunStore } from "./db/runs.js";
16
+ import { CredentialStore } from "./db/credentials.js";
13
17
  import { SecretStore } from "./db/secrets.js";
14
18
  import { buildApp } from "./server/app.js";
15
19
  import { Vault } from "./secrets/vault.js";
16
20
  import { makeInvoke } from "./sidecar/bridge.js";
17
21
  import { LaneReader } from "./sidecar/lanes.js";
18
22
  import { UsesReader } from "./sidecar/uses.js";
19
- export const version = "0.2.0";
23
+ export const version = "0.3.0";
20
24
  /** Assembles the server from a data folder. */
21
25
  export async function createServerFromConfig(root) {
22
26
  const config = new ConfigStore(join(root, "config.yml"));
@@ -28,7 +32,7 @@ export async function createServerFromConfig(root) {
28
32
  // it. Queued runs never began, so they stay queued for the next start.
29
33
  new RunStore(db).interruptInFlight();
30
34
  const cache = new CacheStore(db);
31
- const vault = await Vault.open(root, new SecretStore(db));
35
+ const vault = await Vault.open(root, new SecretStore(db), new CredentialStore(db));
32
36
  const app = await buildApp({
33
37
  config,
34
38
  db,
@@ -61,7 +65,24 @@ async function main() {
61
65
  });
62
66
  const server = config.server();
63
67
  await app.listen({ port: server.port, host: server.bind });
64
- console.log(`Laneyard is listening on http://localhost:${server.port}`);
68
+ const projects = config.projects();
69
+ process.stdout.write(heading(`laneyard ${version}`) +
70
+ field("listening", `http://localhost:${server.port}`) +
71
+ "\n" +
72
+ field("config", join(root, "config.yml")) +
73
+ "\n" +
74
+ field("projects", projects.length === 0
75
+ ? dim("none yet")
76
+ : projects.map((p) => p.slug).join(", ")) +
77
+ "\n" +
78
+ (projects.length === 0
79
+ ? // A server with nothing to build should say what to do next rather
80
+ // than sit there looking successful.
81
+ "\n" +
82
+ dim(" No project yet. From a folder that already uses fastlane:\n") +
83
+ ` ${bold("laneyard setup")}\n`
84
+ : "") +
85
+ "\n");
65
86
  }
66
87
  /**
67
88
  * True when this file is what the user launched, rather than something that
@@ -84,9 +105,12 @@ function invokedDirectly() {
84
105
  }
85
106
  const USAGE = `laneyard — a self-hosted web UI for fastlane
86
107
 
87
- laneyard add adopt the project in the current directory
108
+ laneyard setup set up the project in the current directory
109
+ --yes accepts every detected value without asking
88
110
  laneyard secret set NAME [--project <slug>]
89
111
  store a secret, its value read from standard input
112
+ laneyard user add NAME [--role admin|builder]
113
+ create an account, its password read from standard input
90
114
  laneyard start the server
91
115
  laneyard --version print the version
92
116
 
@@ -101,7 +125,7 @@ function explainStartupFailure(cause) {
101
125
  if (message.includes("ENOENT") && message.includes("config.yml")) {
102
126
  return ("No configuration yet.\n\n" +
103
127
  " cd into a project that already uses fastlane, then run:\n" +
104
- " laneyard add\n\n" +
128
+ " laneyard setup\n\n" +
105
129
  "That writes " + join(homeDir(), "config.yml") + " for you.");
106
130
  }
107
131
  return message;
@@ -116,15 +140,25 @@ if (invokedDirectly()) {
116
140
  process.stdout.write(`${version}\n`);
117
141
  process.exit(0);
118
142
  }
119
- if (command === "add") {
143
+ if (command === "setup") {
120
144
  const home = homeDir();
121
145
  await mkdir(home, { recursive: true });
122
146
  const slugIndex = rest.indexOf("--slug");
123
147
  const slug = slugIndex === -1 ? undefined : rest[slugIndex + 1];
148
+ // `--yes` accepts every proposal, for scripts and for anyone who has done
149
+ // this before. Interactive is the default because the values are guesses.
150
+ const yes = rest.includes("--yes") || rest.includes("-y");
124
151
  try {
125
- process.exit(await runAddCommand(process.cwd(), join(home, "config.yml"), slug));
152
+ process.exit(await runSetupCommand(process.cwd(), join(home, "config.yml"), { slug, yes }));
126
153
  }
127
154
  catch (cause) {
155
+ // Ctrl-C in the middle of the questions. Nothing is written before the
156
+ // last confirmation, so the only thing worth saying is that it is safe to
157
+ // start over. 130 is what a shell expects from a command killed by SIGINT.
158
+ if (cause instanceof PromptAborted) {
159
+ process.stdout.write(`\n${dim("Setup interrupted — nothing was written. Run `laneyard setup` again.")}\n`);
160
+ process.exit(130);
161
+ }
128
162
  // A taken slug or an unreadable file are ordinary situations. A stack
129
163
  // trace is not an error message; it just suggests the tool is broken.
130
164
  process.stderr.write(`${cause.message}\n`);
@@ -142,6 +176,22 @@ if (invokedDirectly()) {
142
176
  err: (text) => process.stderr.write(text),
143
177
  }));
144
178
  }
179
+ if (command === "user") {
180
+ const home = homeDir();
181
+ await mkdir(home, { recursive: true });
182
+ process.exit(await runUserCommand(home, rest, {
183
+ stdin: process.stdin,
184
+ interactive: process.stdin.isTTY === true,
185
+ out: (text) => process.stdout.write(text),
186
+ err: (text) => process.stderr.write(text),
187
+ }));
188
+ }
189
+ // 0.1.0 shipped this as `add`. Anyone who learned that name deserves a
190
+ // sentence rather than "Unknown command".
191
+ if (command === "add") {
192
+ process.stderr.write("`laneyard add` is now `laneyard setup`.\n");
193
+ process.exit(1);
194
+ }
145
195
  if (command !== undefined) {
146
196
  process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
147
197
  process.exit(1);
@@ -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