laneyard 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +295 -20
- package/dist/src/cli/detect.js +69 -16
- package/dist/src/cli/prompt.js +61 -0
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +319 -0
- package/dist/src/cli/style.js +31 -0
- package/dist/src/cli/user.js +127 -0
- package/dist/src/config/accounts.js +163 -0
- package/dist/src/config/load.js +61 -1
- package/dist/src/config/schema.js +26 -1
- package/dist/src/config/store.js +10 -0
- package/dist/src/config/yaml.js +14 -0
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +75 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +28 -0
- package/dist/src/db/sessions.js +61 -0
- package/dist/src/git/workspace.js +32 -6
- package/dist/src/heuristics/android-root.js +49 -0
- package/dist/src/heuristics/android-signing.js +98 -0
- package/dist/src/heuristics/appfile.js +60 -0
- package/dist/src/heuristics/blocking-actions.js +22 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +136 -0
- package/dist/src/heuristics/readiness.js +697 -25
- package/dist/src/main.js +58 -8
- package/dist/src/runner/gradle-properties.js +187 -0
- package/dist/src/runner/materialise.js +92 -0
- package/dist/src/runner/orchestrate.js +91 -2
- package/dist/src/secrets/vault.js +106 -5
- package/dist/src/server/app.js +94 -15
- package/dist/src/server/auth.js +127 -19
- package/dist/src/server/permissions.js +75 -0
- package/dist/src/server/required-secrets.js +30 -0
- package/dist/src/server/routes/account.js +57 -0
- package/dist/src/server/routes/credentials.js +108 -0
- package/dist/src/server/routes/projects.js +42 -0
- package/dist/src/server/routes/readiness.js +175 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/server/routes/users.js +64 -0
- package/dist/src/sidecar/bridge.js +37 -1
- package/dist/src/sidecar/fastlane-dir.js +23 -0
- package/dist/src/sidecar/lanes.js +6 -0
- package/dist/src/sidecar/uses.js +21 -5
- package/dist/web/assets/{Editor-D5Q4uj4n.js → Editor-DynuBC2l.js} +1 -1
- package/dist/web/assets/index-CpwrNE-K.css +1 -0
- package/dist/web/assets/index-lyZs-Y7J.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/ruby/introspect.rb +122 -9
- package/dist/web/assets/index-D9_EL8LA.js +0 -62
- package/dist/web/assets/index-ZUqTX6d1.css +0 -1
package/dist/src/db/schema.sql
CHANGED
|
@@ -61,3 +61,42 @@ CREATE TABLE IF NOT EXISTS secret (
|
|
|
61
61
|
updated_at TEXT NOT NULL,
|
|
62
62
|
PRIMARY KEY (project_slug, key)
|
|
63
63
|
);
|
|
64
|
+
|
|
65
|
+
-- A signing credential: a file plus the fields that make it usable. Separate
|
|
66
|
+
-- from `secret` because these are not key/value pairs — stored as loose rows,
|
|
67
|
+
-- nothing knew the parts belonged together, and deleting one left a half-dead
|
|
68
|
+
-- group no check could detect.
|
|
69
|
+
--
|
|
70
|
+
-- `fields_enc` is one encrypted JSON object rather than a column per field: the
|
|
71
|
+
-- three kinds do not share a shape, and a column per field would mean a
|
|
72
|
+
-- migration every time a kind gains one.
|
|
73
|
+
--
|
|
74
|
+
-- `var_names` is NOT encrypted. It holds variable names, never values, and the
|
|
75
|
+
-- interface has to display them.
|
|
76
|
+
CREATE TABLE IF NOT EXISTS credential (
|
|
77
|
+
project_slug TEXT NOT NULL DEFAULT '',
|
|
78
|
+
kind TEXT NOT NULL,
|
|
79
|
+
file_name TEXT NOT NULL,
|
|
80
|
+
file_enc TEXT NOT NULL,
|
|
81
|
+
fields_enc TEXT NOT NULL,
|
|
82
|
+
var_names TEXT NOT NULL,
|
|
83
|
+
updated_at TEXT NOT NULL,
|
|
84
|
+
PRIMARY KEY (project_slug, kind)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
-- Sessions outlive a restart, which is the whole reason they are here rather
|
|
88
|
+
-- than in a Map. What is stored is a SHA-256 of the token, never the token: the
|
|
89
|
+
-- cookie is a bearer credential, and a stolen `laneyard.db` must not hand
|
|
90
|
+
-- anybody a live session the way a table of raw tokens would.
|
|
91
|
+
--
|
|
92
|
+
-- `expires_at` is an ISO timestamp compared as text, which sorts correctly
|
|
93
|
+
-- because ISO-8601 does. A session with no end is not a convenience, it is a
|
|
94
|
+
-- credential nobody can lose track of.
|
|
95
|
+
CREATE TABLE IF NOT EXISTS session (
|
|
96
|
+
token_hash TEXT PRIMARY KEY,
|
|
97
|
+
name TEXT NOT NULL,
|
|
98
|
+
role TEXT NOT NULL,
|
|
99
|
+
created_at TEXT NOT NULL,
|
|
100
|
+
expires_at TEXT NOT NULL
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS session_by_name ON session (name);
|
package/dist/src/db/secrets.js
CHANGED
|
@@ -34,6 +34,34 @@ export class SecretStore {
|
|
|
34
34
|
}
|
|
35
35
|
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
36
36
|
}
|
|
37
|
+
/** One applicable row, ciphertext included, or undefined. */
|
|
38
|
+
find(projectSlug, key) {
|
|
39
|
+
const row = this.applicable(projectSlug).find((r) => r.key === key);
|
|
40
|
+
return row
|
|
41
|
+
? {
|
|
42
|
+
key: row.key,
|
|
43
|
+
masked: row.masked === 1,
|
|
44
|
+
scope: row.project_slug === GLOBAL ? "global" : "project",
|
|
45
|
+
valueEnc: row.value_enc,
|
|
46
|
+
}
|
|
47
|
+
: undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Flips whether a value is kept out of the logs, leaving the value alone.
|
|
51
|
+
*
|
|
52
|
+
* Its own operation rather than a re-`set`, because of a circle: to reveal a
|
|
53
|
+
* value you must first declare it not secret, and declaring that by storing it
|
|
54
|
+
* again would mean typing the value you were trying to read.
|
|
55
|
+
*
|
|
56
|
+
* Returns false when no row matches, so a caller can answer 404 rather than
|
|
57
|
+
* report a change that did not happen.
|
|
58
|
+
*/
|
|
59
|
+
setMasked(projectSlug, key, masked) {
|
|
60
|
+
return (this.db
|
|
61
|
+
.prepare(`UPDATE secret SET masked = ?, updated_at = ?
|
|
62
|
+
WHERE project_slug = ? AND key = ?`)
|
|
63
|
+
.run(masked ? 1 : 0, new Date().toISOString(), projectSlug ?? GLOBAL, key).changes > 0);
|
|
64
|
+
}
|
|
37
65
|
list(projectSlug) {
|
|
38
66
|
return this.applicable(projectSlug).map((row) => ({
|
|
39
67
|
key: row.key,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* A token is never stored, only its digest.
|
|
4
|
+
*
|
|
5
|
+
* SHA-256 without a salt on purpose, unlike a password: the token is 32 random
|
|
6
|
+
* bytes from `randomBytes`, so there is no dictionary to build and no cheaper
|
|
7
|
+
* attack than guessing the token itself. What this buys is that a copy of
|
|
8
|
+
* `laneyard.db` is a list of digests rather than a ring of working keys.
|
|
9
|
+
*/
|
|
10
|
+
const digest = (token) => createHash("sha256").update(token).digest("hex");
|
|
11
|
+
/**
|
|
12
|
+
* Where sessions live between restarts.
|
|
13
|
+
*
|
|
14
|
+
* They used to live in a Map, with a comment saying they did not survive a
|
|
15
|
+
* restart "and that's just fine". It was not fine: a server restarted to pick
|
|
16
|
+
* up a configuration change signed everybody out, and on a machine you are
|
|
17
|
+
* still setting up that is several times an hour.
|
|
18
|
+
*/
|
|
19
|
+
export class SessionRecords {
|
|
20
|
+
db;
|
|
21
|
+
constructor(db) {
|
|
22
|
+
this.db = db;
|
|
23
|
+
}
|
|
24
|
+
insert(token, owner, expiresAt) {
|
|
25
|
+
this.db
|
|
26
|
+
.prepare(`INSERT INTO session (token_hash, name, role, created_at, expires_at)
|
|
27
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
28
|
+
.run(digest(token), owner.name, owner.role, new Date().toISOString(), expiresAt.toISOString());
|
|
29
|
+
}
|
|
30
|
+
/** The owner, or undefined when the token is unknown or its time is up. */
|
|
31
|
+
find(token, now = new Date()) {
|
|
32
|
+
const row = this.db
|
|
33
|
+
.prepare(`SELECT name, role FROM session WHERE token_hash = ? AND expires_at > ?`)
|
|
34
|
+
.get(digest(token), now.toISOString());
|
|
35
|
+
return row ? { name: row.name, role: row.role } : undefined;
|
|
36
|
+
}
|
|
37
|
+
remove(token) {
|
|
38
|
+
this.db.prepare(`DELETE FROM session WHERE token_hash = ?`).run(digest(token));
|
|
39
|
+
}
|
|
40
|
+
removeAllFor(name) {
|
|
41
|
+
this.db.prepare(`DELETE FROM session WHERE name = ?`).run(name);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Drops what has already expired.
|
|
45
|
+
*
|
|
46
|
+
* Called at startup rather than on a timer: the rows are harmless — `find`
|
|
47
|
+
* already refuses them — so this is housekeeping, not correctness, and a
|
|
48
|
+
* timer would be a moving part earning nothing.
|
|
49
|
+
*/
|
|
50
|
+
prune(now = new Date()) {
|
|
51
|
+
return this.db.prepare(`DELETE FROM session WHERE expires_at <= ?`).run(now.toISOString())
|
|
52
|
+
.changes;
|
|
53
|
+
}
|
|
54
|
+
/** Number of live sessions. Exposed so expiry can be tested without sleeping. */
|
|
55
|
+
count(now = new Date()) {
|
|
56
|
+
const row = this.db
|
|
57
|
+
.prepare(`SELECT COUNT(*) AS n FROM session WHERE expires_at > ?`)
|
|
58
|
+
.get(now.toISOString());
|
|
59
|
+
return row.n;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -7,6 +7,27 @@ const exec = promisify(execFile);
|
|
|
7
7
|
* A clone managed by Laneyard, kept between runs.
|
|
8
8
|
* All git commands go through here to share the authentication environment.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* The environment git needs to work without a person at the keyboard.
|
|
12
|
+
*
|
|
13
|
+
* Lifted out of `Workspace` because it is not only Workspace's problem. A lane
|
|
14
|
+
* may run git itself — pushing a bumped build number is a common and reasonable
|
|
15
|
+
* thing for a Fastfile to do — and that `sh("git push")` inherited none of
|
|
16
|
+
* this. The result was the worst possible failure: a push that needed a
|
|
17
|
+
* credential did not fail, it *waited*, and the run sat there until it hit its
|
|
18
|
+
* timeout with nothing in the log to say what it was waiting for.
|
|
19
|
+
*
|
|
20
|
+
* `GIT_TERMINAL_PROMPT=0` is what turns that wait into an error. The SSH
|
|
21
|
+
* command is what makes the push succeed instead — the key configured for
|
|
22
|
+
* cloning is by definition the right one for pushing to the same remote.
|
|
23
|
+
*/
|
|
24
|
+
export function gitEnvFor(auth) {
|
|
25
|
+
const env = { GIT_TERMINAL_PROMPT: "0" };
|
|
26
|
+
if (auth.kind === "ssh_key" && auth.ref) {
|
|
27
|
+
env["GIT_SSH_COMMAND"] = `ssh -i ${auth.ref} -o IdentitiesOnly=yes -o BatchMode=yes`;
|
|
28
|
+
}
|
|
29
|
+
return env;
|
|
30
|
+
}
|
|
10
31
|
export class Workspace {
|
|
11
32
|
path;
|
|
12
33
|
gitUrl;
|
|
@@ -17,12 +38,17 @@ export class Workspace {
|
|
|
17
38
|
this.auth = auth;
|
|
18
39
|
}
|
|
19
40
|
env() {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
return { ...process.env, ...gitEnvFor(this.auth) };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The git identity this workspace would commit under, or null.
|
|
45
|
+
*
|
|
46
|
+
* Exposed because a lane that runs `git commit` itself needs the same answer,
|
|
47
|
+
* and a clone Laneyard made has no identity of its own — so the question is
|
|
48
|
+
* whether the *server* has one.
|
|
49
|
+
*/
|
|
50
|
+
async identity() {
|
|
51
|
+
return this.gitIdentity();
|
|
26
52
|
}
|
|
27
53
|
/**
|
|
28
54
|
* Replaces the repository URL with a neutral token in a piece of text.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { parseAndroidSigning } from "./android-signing.js";
|
|
4
|
+
/**
|
|
5
|
+
* Which build script speaks for the android side of a project — asked once, in
|
|
6
|
+
* one place.
|
|
7
|
+
*
|
|
8
|
+
* Two callers need the answer and they must never disagree. The checklist reads
|
|
9
|
+
* the script to decide whether a release build could go out signed with the
|
|
10
|
+
* debug key, and the runner writes the properties file that build asks for. If
|
|
11
|
+
* one of them stopped at `app/build.gradle` while the other read
|
|
12
|
+
* `android/app/build.gradle.kts`, the file would land where nothing reads it and
|
|
13
|
+
* the checklist would report green over an artifact signed by the debug key —
|
|
14
|
+
* the exact failure both of them exist to prevent. A shared list is what makes
|
|
15
|
+
* that disagreement impossible rather than unlikely.
|
|
16
|
+
*
|
|
17
|
+
* The order is a preference, not a ranking of correctness: `android/app` is
|
|
18
|
+
* where Flutter and React Native put theirs, and `app` is a repository that is
|
|
19
|
+
* an Android project outright. A repository with both is a monorepo whose
|
|
20
|
+
* fastlane directory should have pointed one level down, and the first match is
|
|
21
|
+
* the honest guess to make until it does.
|
|
22
|
+
*/
|
|
23
|
+
export const ANDROID_BUILD_SCRIPTS = [
|
|
24
|
+
"android/app/build.gradle.kts",
|
|
25
|
+
"android/app/build.gradle",
|
|
26
|
+
"app/build.gradle.kts",
|
|
27
|
+
"app/build.gradle",
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Reads the first android build script under `root`, or null when there is none.
|
|
31
|
+
*
|
|
32
|
+
* `root` is the app root inside the clone, not the repository root: in a
|
|
33
|
+
* monorepo the app is one directory down and so are its platform folders.
|
|
34
|
+
*
|
|
35
|
+
* Never throws. An unreadable script is the same as an absent one to both
|
|
36
|
+
* callers — "could not tell" — and a checklist that raised here would turn a
|
|
37
|
+
* question it failed to answer into an error page.
|
|
38
|
+
*/
|
|
39
|
+
export async function findAndroidBuild(root) {
|
|
40
|
+
for (const candidate of ANDROID_BUILD_SCRIPTS) {
|
|
41
|
+
const scriptPath = join(root, candidate);
|
|
42
|
+
const text = await readFile(scriptPath, "utf8").catch(() => null);
|
|
43
|
+
if (text === null)
|
|
44
|
+
continue;
|
|
45
|
+
const moduleDir = dirname(scriptPath);
|
|
46
|
+
return { scriptPath, moduleDir, gradleRoot: dirname(moduleDir), facts: parseAndroidSigning(text) };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a release build is actually signed with the release key.
|
|
3
|
+
*
|
|
4
|
+
* The pattern this exists for is in the Flutter documentation, and therefore in
|
|
5
|
+
* thousands of projects:
|
|
6
|
+
*
|
|
7
|
+
* signingConfig = if (keystorePropertiesFile.exists()) {
|
|
8
|
+
* signingConfigs.getByName("release")
|
|
9
|
+
* } else {
|
|
10
|
+
* signingConfigs.getByName("debug")
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* It is kind locally — `flutter run --release` works on a machine with no
|
|
14
|
+
* keystore — and it is a trap everywhere else. `key.properties` is gitignored
|
|
15
|
+
* by the same documentation, so it is by definition absent from a clone. The
|
|
16
|
+
* release build then *succeeds*, produces an `.aab` signed with the debug key,
|
|
17
|
+
* and the failure arrives minutes later from Google, saying nothing about
|
|
18
|
+
* signing.
|
|
19
|
+
*
|
|
20
|
+
* Silent, late, and misleading — which is exactly the shape of failure a
|
|
21
|
+
* checklist exists to move forward in time.
|
|
22
|
+
*
|
|
23
|
+
* Text, not a Gradle evaluation: running someone's build script to ask it a
|
|
24
|
+
* question is not something a checklist may do. So this errs towards saying
|
|
25
|
+
* nothing, and the check it feeds says "could not tell" rather than inventing a
|
|
26
|
+
* verdict from a file it half understood.
|
|
27
|
+
*/
|
|
28
|
+
export const NO_SIGNING_FACTS = {
|
|
29
|
+
releaseCanUseDebugKey: false,
|
|
30
|
+
conditionalOn: null,
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* `rootProject.file("key.properties")`, `file('signing.properties')` — the name,
|
|
34
|
+
* and whatever the call was made on.
|
|
35
|
+
*
|
|
36
|
+
* The receiver is optional and captured separately because it is the whole
|
|
37
|
+
* difference between two directories. It is matched as a dotted chain so that
|
|
38
|
+
* the match starts at the receiver rather than at `file(` in the middle of it:
|
|
39
|
+
* a pattern that ignored the receiver would read `keystoreDir.file(…)` as a bare
|
|
40
|
+
* call and confidently name the wrong place.
|
|
41
|
+
*/
|
|
42
|
+
const PROPERTIES_FILE = /(?:([A-Za-z_][\w.]*)\s*\.\s*)?\bfile\s*\(\s*["']([^"']+\.properties)["']\s*\)/;
|
|
43
|
+
/**
|
|
44
|
+
* `project.file` is the bare call spelled out — Gradle defines one as the other
|
|
45
|
+
* — so both mean the module. Only `rootProject` climbs, and anything else is a
|
|
46
|
+
* receiver whose directory this has no way to know.
|
|
47
|
+
*/
|
|
48
|
+
function scopeOf(receiver) {
|
|
49
|
+
if (receiver === undefined || receiver === "project")
|
|
50
|
+
return "module";
|
|
51
|
+
if (receiver === "rootProject")
|
|
52
|
+
return "root";
|
|
53
|
+
return "unknown";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The `release { … }` block of `buildTypes`, as text.
|
|
57
|
+
*
|
|
58
|
+
* Braces are counted rather than matched with a regex, because the block nests
|
|
59
|
+
* and a lazy match would stop at the first `}` — which is usually inside it.
|
|
60
|
+
*/
|
|
61
|
+
function releaseBlock(source) {
|
|
62
|
+
const start = /buildTypes\s*\{/.exec(source);
|
|
63
|
+
if (!start)
|
|
64
|
+
return null;
|
|
65
|
+
const from = source.indexOf("release", start.index);
|
|
66
|
+
if (from === -1)
|
|
67
|
+
return null;
|
|
68
|
+
const open = source.indexOf("{", from);
|
|
69
|
+
if (open === -1)
|
|
70
|
+
return null;
|
|
71
|
+
let depth = 0;
|
|
72
|
+
for (let i = open; i < source.length; i += 1) {
|
|
73
|
+
if (source[i] === "{")
|
|
74
|
+
depth += 1;
|
|
75
|
+
else if (source[i] === "}") {
|
|
76
|
+
depth -= 1;
|
|
77
|
+
if (depth === 0)
|
|
78
|
+
return source.slice(open, i + 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/** Reads an `android/app/build.gradle` or its Kotlin equivalent. Never throws. */
|
|
84
|
+
export function parseAndroidSigning(source) {
|
|
85
|
+
const release = releaseBlock(source);
|
|
86
|
+
if (release === null)
|
|
87
|
+
return NO_SIGNING_FACTS;
|
|
88
|
+
// `signingConfigs.debug` and `signingConfigs.getByName("debug")` are the two
|
|
89
|
+
// spellings; both mean the release build may go out with the debug key.
|
|
90
|
+
const usesDebug = /signingConfigs\s*(?:\.getByName\s*\(\s*["']debug["']\s*\)|\.debug\b)/.test(release);
|
|
91
|
+
const conditional = PROPERTIES_FILE.exec(source);
|
|
92
|
+
return {
|
|
93
|
+
releaseCanUseDebugKey: usesDebug,
|
|
94
|
+
conditionalOn: usesDebug && conditional
|
|
95
|
+
? { name: conditional[2], scope: scopeOf(conditional[1]) }
|
|
96
|
+
: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What fastlane's `Appfile` says about credentials.
|
|
3
|
+
*
|
|
4
|
+
* The Appfile is the other half of a fastlane setup, and the half Laneyard used
|
|
5
|
+
* to be blind to: a project whose Play Store service account has been configured
|
|
6
|
+
* for years — `json_key_file` on line two — was told it had no service account,
|
|
7
|
+
* because the only place the checklist looked was Laneyard's own vault. A
|
|
8
|
+
* warning that is false for a working project is worse than no warning: it is
|
|
9
|
+
* the one that teaches someone the screen is wrong and can be skipped.
|
|
10
|
+
*
|
|
11
|
+
* This is a reader, not an evaluator. The Appfile is Ruby and may compute its
|
|
12
|
+
* values — `json_key_file ENV["KEY"]` is legal and common — so anything that is
|
|
13
|
+
* not a plain string literal is reported as "set, but not to something readable
|
|
14
|
+
* from here" rather than guessed at. The checks turn that into `unknown`, which
|
|
15
|
+
* is the honest answer: the value exists, and whether it resolves to a file on
|
|
16
|
+
* this machine is not a question a text file can settle.
|
|
17
|
+
*/
|
|
18
|
+
const ABSENT = { kind: "absent" };
|
|
19
|
+
export const NO_APPFILE = {
|
|
20
|
+
jsonKeyFile: ABSENT,
|
|
21
|
+
jsonKeyData: ABSENT,
|
|
22
|
+
appleId: ABSENT,
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Strips comments, so `# json_key_file "old.json"` is not read as a setting.
|
|
26
|
+
*
|
|
27
|
+
* Naive about `#` inside a string, deliberately: the alternative is a Ruby
|
|
28
|
+
* lexer, and the cost of being wrong here is one line read as a comment — the
|
|
29
|
+
* check answers "not found" instead of "found", which is the direction this
|
|
30
|
+
* whole module errs in anyway.
|
|
31
|
+
*/
|
|
32
|
+
const withoutComments = (line) => line.replace(/#.*$/, "");
|
|
33
|
+
/**
|
|
34
|
+
* `json_key_file "x"`, `json_key_file("x")`, `json_key_file 'x'` — and the
|
|
35
|
+
* `for_platform`/`for_lane` blocks they may sit inside, which change nothing
|
|
36
|
+
* about whether the key is set, only about when.
|
|
37
|
+
*/
|
|
38
|
+
function read(lines, key) {
|
|
39
|
+
const declaration = new RegExp(`^\\s*${key}\\s*(\\(|\\s)`);
|
|
40
|
+
const literal = new RegExp(`^\\s*${key}\\s*\\(?\\s*(["'])(.*?)\\1\\s*\\)?\\s*$`);
|
|
41
|
+
let found = ABSENT;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (!declaration.test(line))
|
|
44
|
+
continue;
|
|
45
|
+
const match = literal.exec(line);
|
|
46
|
+
// A later assignment wins, the way it would when Ruby runs the file — and a
|
|
47
|
+
// literal that follows a computed one is still the value that lands.
|
|
48
|
+
found = match ? { kind: "literal", value: match[2] } : { kind: "computed" };
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
/** Reads an Appfile's text. Never throws: an unreadable Appfile is `NO_APPFILE`. */
|
|
53
|
+
export function parseAppfile(content) {
|
|
54
|
+
const lines = content.split("\n").map(withoutComments);
|
|
55
|
+
return {
|
|
56
|
+
jsonKeyFile: read(lines, "json_key_file"),
|
|
57
|
+
jsonKeyData: read(lines, "json_key_data"),
|
|
58
|
+
appleId: read(lines, "apple_id"),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -21,7 +21,29 @@ export const BLOCKING_RULES = [
|
|
|
21
21
|
because: "renews provisioning profiles, which needs an Apple account interactively",
|
|
22
22
|
fix: "Use `match` in readonly mode instead, with profiles stored in a repository.",
|
|
23
23
|
},
|
|
24
|
+
{
|
|
25
|
+
action: "cert",
|
|
26
|
+
because: "creates or downloads a signing certificate, which needs an Apple account interactively",
|
|
27
|
+
fix: "Use `match` in readonly mode instead, with certificates stored in a repository.",
|
|
28
|
+
},
|
|
29
|
+
// `deliver` renders an HTML summary and waits for a yes before uploading.
|
|
30
|
+
// Only reported when the lane says `force: false` outright: the default is
|
|
31
|
+
// not something the sidecar reports, and inventing one would be a guess.
|
|
32
|
+
{
|
|
33
|
+
action: "deliver",
|
|
34
|
+
when: { arg: "force", equals: false },
|
|
35
|
+
because: "shows a summary and waits for it to be confirmed before uploading",
|
|
36
|
+
fix: "Pass `force: true` so it uploads without asking.",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
action: "upload_to_app_store",
|
|
40
|
+
when: { arg: "force", equals: false },
|
|
41
|
+
because: "shows a summary and waits for it to be confirmed before uploading",
|
|
42
|
+
fix: "Pass `force: true` so it uploads without asking.",
|
|
43
|
+
},
|
|
24
44
|
];
|
|
45
|
+
/** `given`, with the fallback that keeps an older cached payload readable. */
|
|
46
|
+
export const argsGiven = (action) => action.given ?? Object.keys(action.args);
|
|
25
47
|
/**
|
|
26
48
|
* Applies the table to the actions a lane calls.
|
|
27
49
|
*
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The variable names a `.env.example` advertises.
|
|
3
|
+
*
|
|
4
|
+
* A project that keeps its variables in `fastlane/.env` almost always commits a
|
|
5
|
+
* `.env.example` beside it — that is what the file is *for*: telling whoever
|
|
6
|
+
* clones the repository which variables they need. It is the one manifest of
|
|
7
|
+
* required variables that is both conventional and, unlike `.env` itself,
|
|
8
|
+
* actually present in the clone a build runs from.
|
|
9
|
+
*
|
|
10
|
+
* It catches what parsing the Fastfile cannot. `SENTRY_AUTH_TOKEN` is read by
|
|
11
|
+
* `sentry-cli`, not by any `ENV.fetch` in a lane, so no amount of reading the
|
|
12
|
+
* Fastfile finds it — and it sits in `.env.example`, named, all along.
|
|
13
|
+
*
|
|
14
|
+
* Only names are taken. The file is an example by definition, so its values are
|
|
15
|
+
* placeholders; reading them would be reading fiction, and a checklist that
|
|
16
|
+
* compared them to anything would be worse than one that did not look.
|
|
17
|
+
*/
|
|
18
|
+
export function parseEnvExample(content) {
|
|
19
|
+
const names = [];
|
|
20
|
+
for (const raw of content.split("\n")) {
|
|
21
|
+
const line = raw.trim();
|
|
22
|
+
if (line === "" || line.startsWith("#"))
|
|
23
|
+
continue;
|
|
24
|
+
// `export FOO=bar` is legal in these files and means the same thing.
|
|
25
|
+
const assignment = line.replace(/^export\s+/, "");
|
|
26
|
+
const eq = assignment.indexOf("=");
|
|
27
|
+
if (eq <= 0)
|
|
28
|
+
continue;
|
|
29
|
+
const name = assignment.slice(0, eq).trim();
|
|
30
|
+
// Shell-ish variable names only: anything else is a line this has
|
|
31
|
+
// misunderstood, and inventing a requirement is worse than missing one.
|
|
32
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
33
|
+
names.push(name);
|
|
34
|
+
}
|
|
35
|
+
return [...new Set(names)];
|
|
36
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Where to look for the markers, given where the Fastfile turned out to be.
|
|
4
|
+
*
|
|
5
|
+
* Two places, and the order does not matter because the answer is a union.
|
|
6
|
+
*
|
|
7
|
+
* The repository root is the obvious one, and used to be the only one. It is
|
|
8
|
+
* wrong on its own: the markers sit *beside* an app's fastlane folder, not
|
|
9
|
+
* beside the repository root — `ios/Runner.xcodeproj` and `fastlane/` are
|
|
10
|
+
* siblings, and both move together when the app is one directory of a
|
|
11
|
+
* monorepo. A repository holding `app/fastlane/Fastfile` and
|
|
12
|
+
* `app/ios/Runner.xcodeproj` reported "no Xcode project and no Gradle build",
|
|
13
|
+
* because that is three levels down and the table reaches two.
|
|
14
|
+
*
|
|
15
|
+
* The app's own directory is the second, and it is the one laneyard already
|
|
16
|
+
* knows: it asked where the Fastfile was during setup. Between them they cover
|
|
17
|
+
* the arrangements that actually occur —
|
|
18
|
+
*
|
|
19
|
+
* - `fastlane/` and `*.xcodeproj` both at the root: a plain native app;
|
|
20
|
+
* - `fastlane/`, `ios/`, `android/` at the root: React Native, Flutter;
|
|
21
|
+
* - `app/fastlane/`, `app/ios/`, `app/android/`: the same app inside a monorepo;
|
|
22
|
+
* - `ios/fastlane/`: a fastlane set up for one platform only, which then
|
|
23
|
+
* reports that one platform and not its sibling — right, since those lanes
|
|
24
|
+
* build one platform, and an irrelevant section is what teaches someone to
|
|
25
|
+
* ignore the screen.
|
|
26
|
+
*
|
|
27
|
+
* Keeping both roots rather than replacing one with the other is what makes
|
|
28
|
+
* this strictly an addition: nothing the old behaviour found is lost.
|
|
29
|
+
*
|
|
30
|
+
* What is deliberately *not* done is deepening the table to `*/*/*` or `**`.
|
|
31
|
+
* A third level would match `node_modules/some-package/ios/X.xcodeproj` and
|
|
32
|
+
* `app/ios/Pods/Pods.xcodeproj`, and report iOS for an Android-only project on
|
|
33
|
+
* the strength of a dependency's own Xcode project. `**` would do that and walk
|
|
34
|
+
* every build directory to do it. Two levels from a root that is actually the
|
|
35
|
+
* app is a better question than four levels from a root that is not.
|
|
36
|
+
*/
|
|
37
|
+
export function appRootOf(fastlaneDir) {
|
|
38
|
+
if (!fastlaneDir)
|
|
39
|
+
return ".";
|
|
40
|
+
// Repository-relative and always forward-slashed, whatever the platform.
|
|
41
|
+
const parent = fastlaneDir.replace(/\/+$/, "").split("/").slice(0, -1).join("/");
|
|
42
|
+
return parent === "" ? "." : parent;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The one directory to list, given where the Fastfile turned out to be.
|
|
46
|
+
*
|
|
47
|
+
* Not the repository root, which is what it used to be and what made a
|
|
48
|
+
* repository holding `app/fastlane/Fastfile` and `app/ios/Runner.xcodeproj`
|
|
49
|
+
* report "no Xcode project and no Gradle build": that is three levels down and
|
|
50
|
+
* the table reaches two.
|
|
51
|
+
*
|
|
52
|
+
* Not both, either, though that was tempting — keeping the root as well would
|
|
53
|
+
* have made this purely additive. It also means a project configured with
|
|
54
|
+
* `ios/fastlane` is shown the Android section, on the strength of a sibling
|
|
55
|
+
* `android/` folder those lanes never touch. The fastlane directory is not a
|
|
56
|
+
* guess: setup asked for it and `laneyard.yml` records it, and it says which
|
|
57
|
+
* app this project *is*. Trusting it is the difference between a checklist
|
|
58
|
+
* about this project and a checklist about the repository it lives in.
|
|
59
|
+
*
|
|
60
|
+
* Anyone it gets wrong has the escape hatch the check itself names: `platforms`
|
|
61
|
+
* in `laneyard.yml` is read first and skips all of this.
|
|
62
|
+
*
|
|
63
|
+
* What is deliberately not done is deepening the table to `*/*/*` or `**`. A
|
|
64
|
+
* third level matches `node_modules/some-package/ios/X.xcodeproj` and
|
|
65
|
+
* `app/ios/Pods/Pods.xcodeproj`, and would report iOS for an Android-only
|
|
66
|
+
* project on the strength of a dependency. Two levels from a directory that is
|
|
67
|
+
* actually the app beats four from one that is not.
|
|
68
|
+
*/
|
|
69
|
+
export function searchDir(from, appRoot) {
|
|
70
|
+
if (!appRoot || appRoot === "." || appRoot.startsWith(".."))
|
|
71
|
+
return from;
|
|
72
|
+
return join(from, appRoot);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* What a repository looks like from the outside, as a table.
|
|
76
|
+
*
|
|
77
|
+
* The depth is deliberate: an app in a monorepo sits one level down
|
|
78
|
+
* (`android/build.gradle`, `ios/App.xcodeproj`), which is where Flutter and
|
|
79
|
+
* React Native put them.
|
|
80
|
+
*/
|
|
81
|
+
export const PLATFORM_MARKERS = [
|
|
82
|
+
{
|
|
83
|
+
platform: "ios",
|
|
84
|
+
globs: ["*.xcodeproj", "*.xcworkspace", "*/*.xcodeproj", "*/*.xcworkspace"],
|
|
85
|
+
onlyDirectories: true,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
platform: "android",
|
|
89
|
+
globs: ["build.gradle", "build.gradle.kts", "*/build.gradle", "*/build.gradle.kts"],
|
|
90
|
+
onlyDirectories: false,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
/**
|
|
94
|
+
* What the repository contains.
|
|
95
|
+
*
|
|
96
|
+
* Never throws. An absent clone is a reason to answer "nothing found", not to
|
|
97
|
+
* fail the checklist that was about to explain why the clone is absent.
|
|
98
|
+
*/
|
|
99
|
+
export async function detectPlatforms(find) {
|
|
100
|
+
const found = [];
|
|
101
|
+
// Sequential, and in the table's order, so the answer is stable: this list
|
|
102
|
+
// decides the order of the sections someone reads.
|
|
103
|
+
for (const marker of PLATFORM_MARKERS) {
|
|
104
|
+
try {
|
|
105
|
+
const paths = await find(marker.globs, { onlyDirectories: marker.onlyDirectories });
|
|
106
|
+
if (paths.length > 0)
|
|
107
|
+
found.push(marker.platform);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// A marker that cannot be looked for is simply not found.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The configuration first, then the repository.
|
|
117
|
+
*
|
|
118
|
+
* An empty configured list counts as saying nothing: `platforms: []` is what a
|
|
119
|
+
* commented-out line leaves behind, and reading it as "this project builds for
|
|
120
|
+
* nothing" would silently empty the checklist.
|
|
121
|
+
*/
|
|
122
|
+
export function platformsOf(configured, detected) {
|
|
123
|
+
const chosen = configured && configured.length > 0 ? configured : detected;
|
|
124
|
+
// Normalised through the table: a hand-written `[android, ios]` must not
|
|
125
|
+
// reorder the screen, and a platform named twice must not appear twice.
|
|
126
|
+
return PLATFORM_MARKERS.map((m) => m.platform).filter((p) => chosen.includes(p));
|
|
127
|
+
}
|
|
128
|
+
/** The two steps in one call, for callers that have a directory to look at. */
|
|
129
|
+
export async function resolvePlatforms(configured, find) {
|
|
130
|
+
// The repository is only listed when the configuration did not answer:
|
|
131
|
+
// globbing a workspace costs a directory walk, and a project that says what
|
|
132
|
+
// it builds for should not pay for one.
|
|
133
|
+
if (configured && configured.length > 0)
|
|
134
|
+
return platformsOf(configured, []);
|
|
135
|
+
return platformsOf(undefined, await detectPlatforms(find));
|
|
136
|
+
}
|