laneyard 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -21
- package/dist/src/cli/secret.js +133 -0
- package/dist/src/config/schema.js +21 -3
- package/dist/src/db/cache.js +8 -8
- package/dist/src/db/open.js +15 -0
- package/dist/src/db/runs.js +51 -6
- package/dist/src/db/schema.sql +20 -2
- package/dist/src/db/secrets.js +64 -0
- package/dist/src/fastfile/store.js +53 -0
- package/dist/src/git/workspace.js +95 -5
- package/dist/src/heuristics/blocking-actions.js +45 -0
- package/dist/src/heuristics/readiness.js +193 -0
- package/dist/src/logs/redact.js +86 -0
- package/dist/src/main.js +32 -3
- package/dist/src/runner/orchestrate.js +60 -10
- package/dist/src/runner/pty.js +27 -15
- package/dist/src/runner/queue.js +114 -0
- package/dist/src/secrets/cipher.js +28 -0
- package/dist/src/secrets/key.js +40 -0
- package/dist/src/secrets/vault.js +68 -0
- package/dist/src/server/app.js +70 -0
- package/dist/src/server/routes/fastfile.js +131 -0
- package/dist/src/server/routes/projects.js +8 -1
- package/dist/src/server/routes/readiness.js +89 -0
- package/dist/src/server/routes/runs.js +27 -45
- package/dist/src/server/routes/secrets.js +51 -0
- package/dist/src/sidecar/lanes.js +2 -2
- package/dist/src/sidecar/uses.js +40 -0
- package/dist/web/assets/Editor-D5Q4uj4n.js +14 -0
- package/dist/web/assets/index-D9_EL8LA.js +62 -0
- package/dist/web/assets/index-ZUqTX6d1.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +13 -5
- package/ruby/introspect.rb +80 -0
- package/dist/tests/cli/add.test.js +0 -64
- package/dist/tests/cli/detect.test.js +0 -63
- package/dist/tests/config/load.test.js +0 -68
- package/dist/tests/config/resolve.test.js +0 -44
- package/dist/tests/config/store.test.js +0 -58
- package/dist/tests/db/runs.test.js +0 -54
- package/dist/tests/e2e/full-thread.test.js +0 -65
- package/dist/tests/fixtures/repos.js +0 -30
- package/dist/tests/git/workspace.test.js +0 -66
- package/dist/tests/heuristics/error-summary.test.js +0 -31
- package/dist/tests/logs/store.test.js +0 -38
- package/dist/tests/main.test.js +0 -27
- package/dist/tests/ruby/introspect.test.js +0 -59
- package/dist/tests/runner/artifacts.test.js +0 -49
- package/dist/tests/runner/live-steps.test.js +0 -35
- package/dist/tests/runner/orchestrate.test.js +0 -124
- package/dist/tests/runner/pty.test.js +0 -53
- package/dist/tests/runner/report.test.js +0 -91
- package/dist/tests/server/api.test.js +0 -132
- package/dist/tests/server/auth.test.js +0 -53
- package/dist/tests/server/ws.test.js +0 -43
- package/dist/tests/sidecar/lanes.test.js +0 -52
- package/dist/tests/sidecar/ruby-env.test.js +0 -25
- package/dist/tests/smoke.test.js +0 -7
- package/dist/web/assets/index-583x0xuo.css +0 -1
- package/dist/web/assets/index-BEpABKPS.js +0 -61
|
@@ -29,16 +29,33 @@ export class Workspace {
|
|
|
29
29
|
*
|
|
30
30
|
* An HTTPS URL can carry a password — `https://user:token@github.com/…`
|
|
31
31
|
* is perfectly legal in `config.yml`. But git errors end up in the run's
|
|
32
|
-
* log file.
|
|
33
|
-
*
|
|
32
|
+
* log file. The vault's redaction does not help here: the repository URL is
|
|
33
|
+
* configuration, not a stored secret, so this leak — which comes from our own
|
|
34
|
+
* formatting — has to be closed on the spot.
|
|
34
35
|
*/
|
|
35
36
|
redact(text) {
|
|
36
37
|
return text.split(this.gitUrl).join("<repository>");
|
|
37
38
|
}
|
|
38
|
-
async git(args, cwd = this.path) {
|
|
39
|
+
async git(args, cwd = this.path, timeout) {
|
|
40
|
+
return (await this.gitRaw(args, cwd, timeout)).trim();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Same as `git`, but without trimming the output.
|
|
44
|
+
*
|
|
45
|
+
* `status --porcelain` output is column-sensitive: its first two characters
|
|
46
|
+
* are a status code that can themselves be a literal space. Trimming the
|
|
47
|
+
* whole blob — fine for a commit hash or a single config value — would eat
|
|
48
|
+
* that leading space and misalign every line parsed after it.
|
|
49
|
+
*/
|
|
50
|
+
async gitRaw(args, cwd = this.path, timeout) {
|
|
39
51
|
try {
|
|
40
|
-
const { stdout } = await exec("git", args, {
|
|
41
|
-
|
|
52
|
+
const { stdout } = await exec("git", args, {
|
|
53
|
+
cwd,
|
|
54
|
+
env: this.env(),
|
|
55
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
56
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
57
|
+
});
|
|
58
|
+
return stdout;
|
|
42
59
|
}
|
|
43
60
|
catch (cause) {
|
|
44
61
|
const err = cause;
|
|
@@ -46,6 +63,18 @@ export class Workspace {
|
|
|
46
63
|
throw new Error(`git ${this.redact(args.join(" "))} failed: ${this.redact(detail)}`);
|
|
47
64
|
}
|
|
48
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Asks the remote for its branches, and nothing else.
|
|
68
|
+
*
|
|
69
|
+
* Reads no working copy and writes nothing, so it answers the one question
|
|
70
|
+
* the readiness checklist asks: would a run reach this repository on its own?
|
|
71
|
+
* The timeout matters as much as the exit code — with `GIT_TERMINAL_PROMPT=0`
|
|
72
|
+
* git gives up on a credentials prompt, but a host that never answers would
|
|
73
|
+
* otherwise hang the checklist exactly the way it hangs a run at 2am.
|
|
74
|
+
*/
|
|
75
|
+
async probeRemote(timeoutMs = 10_000) {
|
|
76
|
+
await this.git(["ls-remote", "--heads", this.gitUrl], process.cwd(), timeoutMs);
|
|
77
|
+
}
|
|
49
78
|
async exists() {
|
|
50
79
|
try {
|
|
51
80
|
await access(join(this.path, ".git"));
|
|
@@ -71,6 +100,67 @@ export class Workspace {
|
|
|
71
100
|
async headSha() {
|
|
72
101
|
return this.git(["rev-parse", "HEAD"]);
|
|
73
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Paths with uncommitted changes, tracked only — the same rule as
|
|
105
|
+
* `isDirty`, for the same reason: a build scatters untracked files around,
|
|
106
|
+
* and listing them here would bury the change someone actually made.
|
|
107
|
+
*/
|
|
108
|
+
async status() {
|
|
109
|
+
const raw = await this.gitRaw(["status", "--porcelain", "--untracked-files=no"]);
|
|
110
|
+
// Porcelain v1: two status letters, a space, then the path. The trailing
|
|
111
|
+
// split produces one empty element for the final newline; drop it rather
|
|
112
|
+
// than turn it into a bogus empty path.
|
|
113
|
+
return raw
|
|
114
|
+
.split("\n")
|
|
115
|
+
.filter((line) => line.length > 0)
|
|
116
|
+
.map((line) => line.slice(3));
|
|
117
|
+
}
|
|
118
|
+
/** The unified diff of uncommitted changes, as text. Every path if none is given. */
|
|
119
|
+
async diff(path) {
|
|
120
|
+
return this.git(path === undefined ? ["diff"] : ["diff", "--", path]);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Stages exactly the given paths and commits them — never `git add -A`. A
|
|
124
|
+
* build leaves files scattered in the workspace, and committing them
|
|
125
|
+
* because they happened to be there is how something ends up in someone's
|
|
126
|
+
* release.
|
|
127
|
+
*
|
|
128
|
+
* Commits under the repository's own git identity if it has one; otherwise
|
|
129
|
+
* as `Laneyard <laneyard@localhost>`, because a commit from a name nobody
|
|
130
|
+
* recognises is worse than one that admits what made it. The identity
|
|
131
|
+
* actually used is returned so the interface can say so.
|
|
132
|
+
*/
|
|
133
|
+
async commit(message, paths) {
|
|
134
|
+
if (paths.length === 0)
|
|
135
|
+
throw new Error("commit: no paths given");
|
|
136
|
+
await this.git(["add", "--", ...paths]);
|
|
137
|
+
const identity = await this.gitIdentity();
|
|
138
|
+
const author = identity ?? "Laneyard <laneyard@localhost>";
|
|
139
|
+
const asLaneyard = identity
|
|
140
|
+
? []
|
|
141
|
+
: ["-c", "user.name=Laneyard", "-c", "user.email=laneyard@localhost"];
|
|
142
|
+
await this.git([...asLaneyard, "commit", "-m", message]);
|
|
143
|
+
return { author };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* `name <email>` from the repository's own git configuration — local or
|
|
147
|
+
* global, however git itself resolves it for this workspace — or null if
|
|
148
|
+
* none is set at all.
|
|
149
|
+
*/
|
|
150
|
+
async gitIdentity() {
|
|
151
|
+
try {
|
|
152
|
+
const name = await this.git(["config", "user.name"]);
|
|
153
|
+
const email = await this.git(["config", "user.email"]);
|
|
154
|
+
return name && email ? `${name} <${email}>` : null;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Pushes the branch, surfacing git's own message on failure rather than a generic one. */
|
|
161
|
+
async push(branch) {
|
|
162
|
+
await this.git(["push", "origin", branch]);
|
|
163
|
+
}
|
|
74
164
|
/**
|
|
75
165
|
* Guarantees the clone is present, without touching the current branch.
|
|
76
166
|
*
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const BLOCKING_RULES = [
|
|
2
|
+
{
|
|
3
|
+
action: "prompt",
|
|
4
|
+
because: "asks a question and waits for an answer",
|
|
5
|
+
fix: "Remove it from the lane, or give it a default that applies when `CI` is set.",
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
action: "match",
|
|
9
|
+
when: { arg: "readonly", equals: false },
|
|
10
|
+
because: "may create certificates, which needs an Apple account interactively",
|
|
11
|
+
fix: "Pass `readonly: true` so it only fetches what already exists.",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
action: "sync_code_signing",
|
|
15
|
+
when: { arg: "readonly", equals: false },
|
|
16
|
+
because: "may create certificates, which needs an Apple account interactively",
|
|
17
|
+
fix: "Pass `readonly: true` so it only fetches what already exists.",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
action: "sigh",
|
|
21
|
+
because: "renews provisioning profiles, which needs an Apple account interactively",
|
|
22
|
+
fix: "Use `match` in readonly mode instead, with profiles stored in a repository.",
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Applies the table to the actions a lane calls.
|
|
27
|
+
*
|
|
28
|
+
* A rule whose `when` names an argument the lane didn't pass literally is not
|
|
29
|
+
* reported: the sidecar already dropped that argument rather than guess at
|
|
30
|
+
* it (see `ruby/introspect.rb`), so `arg in action.args` is false and the
|
|
31
|
+
* rule stays silent instead of pretending to know the answer.
|
|
32
|
+
*/
|
|
33
|
+
export function findBlockingActions(actions) {
|
|
34
|
+
const findings = [];
|
|
35
|
+
for (const action of actions) {
|
|
36
|
+
for (const rule of BLOCKING_RULES) {
|
|
37
|
+
if (rule.action !== action.name)
|
|
38
|
+
continue;
|
|
39
|
+
if (rule.when && action.args[rule.when.arg] !== rule.when.equals)
|
|
40
|
+
continue;
|
|
41
|
+
findings.push({ action: rule.action, because: rule.because, fix: rule.fix });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return findings;
|
|
45
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { findBlockingActions } from "./blocking-actions.js";
|
|
2
|
+
const ok = (detail) => ({ state: "ok", detail });
|
|
3
|
+
const warn = (detail, fix, fixIn) => ({
|
|
4
|
+
state: "warn",
|
|
5
|
+
detail,
|
|
6
|
+
...(fix === undefined ? {} : { fix }),
|
|
7
|
+
...(fixIn === undefined ? {} : { fixIn }),
|
|
8
|
+
});
|
|
9
|
+
const undetermined = (detail, fix) => ({
|
|
10
|
+
state: "unknown",
|
|
11
|
+
detail,
|
|
12
|
+
...(fix === undefined ? {} : { fix }),
|
|
13
|
+
});
|
|
14
|
+
/** A rejection is not always an `Error`; a checklist saying "undefined" is worse than useless. */
|
|
15
|
+
const reasonOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
16
|
+
/** Identity and wording live here so a check and its fallback cannot drift apart. */
|
|
17
|
+
const META = {
|
|
18
|
+
repository: { id: "repository", title: "repository reachable without a password" },
|
|
19
|
+
dependencies: { id: "dependencies", title: "dependencies installable" },
|
|
20
|
+
appStoreConnect: { id: "app-store-connect", title: "App Store Connect authentication" },
|
|
21
|
+
match: { id: "match", title: "match usable without intervention" },
|
|
22
|
+
blockingActions: { id: "blocking-actions", title: "no action known to stop and ask" },
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Does the remote answer without asking anyone for credentials?
|
|
26
|
+
*
|
|
27
|
+
* The probe is injected — `Workspace` already knows how to run git with
|
|
28
|
+
* `GIT_TERMINAL_PROMPT=0` and how to keep the repository URL, which may carry a
|
|
29
|
+
* token, out of its own error messages. Redacting is the probe's job, not this
|
|
30
|
+
* check's: whatever it rejects with is repeated verbatim.
|
|
31
|
+
*/
|
|
32
|
+
export async function checkRepository(probe) {
|
|
33
|
+
try {
|
|
34
|
+
await probe();
|
|
35
|
+
return { ...META.repository, ...ok("the remote answers without asking for credentials.") };
|
|
36
|
+
}
|
|
37
|
+
catch (cause) {
|
|
38
|
+
return {
|
|
39
|
+
...META.repository,
|
|
40
|
+
...warn(reasonOf(cause), "Give the project a key it can use without a passphrase — " +
|
|
41
|
+
"`git_auth: {kind: ssh_key, ref: /path/to/key}` in config.yml — or a URL that needs no password. " +
|
|
42
|
+
"A run that meets a credentials prompt does not fail: it waits."),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function checkDependencies(input) {
|
|
47
|
+
const { workspace } = input;
|
|
48
|
+
if (!workspace.ok) {
|
|
49
|
+
return { ...META.dependencies, ...undetermined(`could not read the workspace: ${workspace.reason}`) };
|
|
50
|
+
}
|
|
51
|
+
if (workspace.value.hasGemfile) {
|
|
52
|
+
try {
|
|
53
|
+
await input.bundleCheck();
|
|
54
|
+
return { ...META.dependencies, ...ok("the Gemfile's bundle is installed.") };
|
|
55
|
+
}
|
|
56
|
+
catch (cause) {
|
|
57
|
+
return {
|
|
58
|
+
...META.dependencies,
|
|
59
|
+
...warn(reasonOf(cause), "Run `bundle install` in the project's workspace."),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let fastlane;
|
|
64
|
+
try {
|
|
65
|
+
fastlane = await input.findFastlane();
|
|
66
|
+
}
|
|
67
|
+
catch (cause) {
|
|
68
|
+
return { ...META.dependencies, ...undetermined(`could not look for fastlane: ${reasonOf(cause)}`) };
|
|
69
|
+
}
|
|
70
|
+
if (fastlane !== null) {
|
|
71
|
+
// Not a failure, but a fact worth stating: a system fastlane can be
|
|
72
|
+
// upgraded underneath a project that was working yesterday.
|
|
73
|
+
return {
|
|
74
|
+
...META.dependencies,
|
|
75
|
+
...ok(`no Gemfile — runs use ${fastlane}, whose version nothing pins.`),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
...META.dependencies,
|
|
80
|
+
...warn("no Gemfile, and no fastlane on the PATH: a run has nothing to execute.", 'Add a Gemfile with `gem "fastlane"` and run `bundle install`, or install fastlane system-wide.'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Any suffix: the key is split across several variables, and their names vary by lane. */
|
|
84
|
+
const API_KEY = /^APP_STORE_CONNECT_API_KEY/;
|
|
85
|
+
const STORE_API_KEY = "Store an App Store Connect API key — `APP_STORE_CONNECT_API_KEY_ID`, " +
|
|
86
|
+
"`APP_STORE_CONNECT_API_KEY_ISSUER_ID` and the `.p8` contents — from the secrets tab. " +
|
|
87
|
+
"An API key does not expire on its own.";
|
|
88
|
+
export function checkAppStoreConnect(secretKeys) {
|
|
89
|
+
if (secretKeys.some((key) => API_KEY.test(key))) {
|
|
90
|
+
return { ...META.appStoreConnect, ...ok("an App Store Connect API key is in the vault.") };
|
|
91
|
+
}
|
|
92
|
+
if (secretKeys.includes("FASTLANE_SESSION")) {
|
|
93
|
+
return {
|
|
94
|
+
...META.appStoreConnect,
|
|
95
|
+
...warn("only a FASTLANE_SESSION: Apple sessions expire, and the run that finds it expired " +
|
|
96
|
+
"stops and asks for a verification code.", STORE_API_KEY, "secrets"),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
...META.appStoreConnect,
|
|
101
|
+
...warn("no App Store Connect credential in the vault: a lane that uploads will ask for an Apple ID.", STORE_API_KEY, "secrets"),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** The two names for the same action. `match` is the older one, still the common one. */
|
|
105
|
+
const MATCH_ACTIONS = new Set(["match", "sync_code_signing"]);
|
|
106
|
+
/** "beta and release" reads better than a list of one, and than a trailing comma. */
|
|
107
|
+
function nameList(names) {
|
|
108
|
+
const unique = [...new Set(names)];
|
|
109
|
+
if (unique.length <= 1)
|
|
110
|
+
return unique[0] ?? "";
|
|
111
|
+
return `${unique.slice(0, -1).join(", ")} and ${unique[unique.length - 1]}`;
|
|
112
|
+
}
|
|
113
|
+
export function checkMatch(uses, secretKeys) {
|
|
114
|
+
if (!uses.ok) {
|
|
115
|
+
return { ...META.match, ...undetermined(`could not read the lanes: ${uses.reason}`) };
|
|
116
|
+
}
|
|
117
|
+
const calls = uses.value.flatMap((lane) => lane.actions.filter((a) => MATCH_ACTIONS.has(a.name)).map((action) => ({ lane: lane.lane, action })));
|
|
118
|
+
if (calls.length === 0) {
|
|
119
|
+
return { ...META.match, ...ok("no lane uses match.") };
|
|
120
|
+
}
|
|
121
|
+
if (!secretKeys.includes("MATCH_PASSWORD")) {
|
|
122
|
+
return {
|
|
123
|
+
...META.match,
|
|
124
|
+
...warn(`${nameList(calls.map((c) => c.lane))} calls match, but MATCH_PASSWORD is not in the vault: ` +
|
|
125
|
+
"match asks for the passphrase and waits for it.", "Store `MATCH_PASSWORD` from the secrets tab.", "secrets"),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const writable = calls.filter((c) => c.action.args["readonly"] === false);
|
|
129
|
+
if (writable.length > 0) {
|
|
130
|
+
return {
|
|
131
|
+
...META.match,
|
|
132
|
+
...warn(`${nameList(writable.map((c) => c.lane))} calls match with \`readonly: false\`: it may try to ` +
|
|
133
|
+
"create certificates, which needs an Apple account interactively.", "Pass `readonly: true` so it only fetches what already exists."),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// `match(readonly: ENV["RO"])` arrives with no `readonly` at all: the sidecar
|
|
137
|
+
// reports literal arguments and refuses to guess at the rest. Calling that
|
|
138
|
+
// green would be the checklist claiming to know something it does not.
|
|
139
|
+
const undecided = calls.filter((c) => !("readonly" in c.action.args));
|
|
140
|
+
if (undecided.length > 0) {
|
|
141
|
+
return {
|
|
142
|
+
...META.match,
|
|
143
|
+
...undetermined(`MATCH_PASSWORD is stored, but \`readonly\` is not a literal in ` +
|
|
144
|
+
`${nameList(undecided.map((c) => c.lane))}: Laneyard reads literal arguments only, and will not guess.`, "Pass `readonly: true` in the call itself if that is what you mean."),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { ...META.match, ...ok("MATCH_PASSWORD is stored, and every match call is readonly.") };
|
|
148
|
+
}
|
|
149
|
+
export function checkBlockingActions(uses) {
|
|
150
|
+
if (!uses.ok) {
|
|
151
|
+
return { ...META.blockingActions, ...undetermined(`could not read the lanes: ${uses.reason}`) };
|
|
152
|
+
}
|
|
153
|
+
const findings = uses.value.flatMap((lane) => findBlockingActions(lane.actions).map((finding) => ({ lane: lane.lane, finding })));
|
|
154
|
+
if (findings.length === 0) {
|
|
155
|
+
return { ...META.blockingActions, ...ok("no lane calls an action known to stop and ask.") };
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
...META.blockingActions,
|
|
159
|
+
...warn(findings.map((f) => `${f.lane} calls ${f.finding.action}: it ${f.finding.because}`).join("; ") + ".",
|
|
160
|
+
// The same fix stated twice reads as two different fixes.
|
|
161
|
+
[...new Set(findings.map((f) => f.finding.fix))].join(" ")),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The checklist itself: a table, so that adding a check is adding a row.
|
|
166
|
+
*
|
|
167
|
+
* Exported because the order it declares is the order the interface shows, and
|
|
168
|
+
* a test that checks that should read the same list.
|
|
169
|
+
*/
|
|
170
|
+
export const CHECKS = [
|
|
171
|
+
{ ...META.repository, run: (i) => checkRepository(i.probeRepository) },
|
|
172
|
+
{ ...META.dependencies, run: (i) => checkDependencies(i.dependencies) },
|
|
173
|
+
{ ...META.appStoreConnect, run: (i) => checkAppStoreConnect(i.secretKeys) },
|
|
174
|
+
{ ...META.match, run: (i) => checkMatch(i.uses, i.secretKeys) },
|
|
175
|
+
{ ...META.blockingActions, run: (i) => checkBlockingActions(i.uses) },
|
|
176
|
+
];
|
|
177
|
+
/**
|
|
178
|
+
* Runs every check, and guarantees the list comes back whole.
|
|
179
|
+
*
|
|
180
|
+
* Each check already commits to not throwing. This wrapper exists because that
|
|
181
|
+
* promise is worth exactly as much as the next person to edit a check: one
|
|
182
|
+
* probe misbehaving must cost one line of the checklist, not the checklist.
|
|
183
|
+
*/
|
|
184
|
+
export async function runChecks(input) {
|
|
185
|
+
return Promise.all(CHECKS.map(async ({ id, title, run }) => {
|
|
186
|
+
try {
|
|
187
|
+
return await run(input);
|
|
188
|
+
}
|
|
189
|
+
catch (cause) {
|
|
190
|
+
return { id, title, state: "unknown", detail: `the check itself failed: ${reasonOf(cause)}` };
|
|
191
|
+
}
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** What a redacted value is replaced with. Fixed width, so it leaks no length. */
|
|
2
|
+
const MARKER = "••••••";
|
|
3
|
+
/**
|
|
4
|
+
* Values shorter than this are left alone.
|
|
5
|
+
*
|
|
6
|
+
* A two-character secret would match constantly and turn the log into confetti
|
|
7
|
+
* while hiding nothing an attacker could not guess. Refusing is more honest than
|
|
8
|
+
* pretending to protect it.
|
|
9
|
+
*/
|
|
10
|
+
export const MIN_LENGTH = 4;
|
|
11
|
+
/**
|
|
12
|
+
* Removes secret values from a stream of text.
|
|
13
|
+
*
|
|
14
|
+
* The difficulty is not the replacement, it is the boundaries: a pseudo-terminal
|
|
15
|
+
* cuts its output wherever it likes, so a secret can arrive as `hun` then `ter2`.
|
|
16
|
+
* Replacing chunk by chunk would let it through in two pieces — and the file on
|
|
17
|
+
* disk would contain it in full.
|
|
18
|
+
*
|
|
19
|
+
* So the redactor holds back the last few characters, exactly as many as could
|
|
20
|
+
* still turn out to be the beginning of a secret, and releases them only once
|
|
21
|
+
* they cannot. `flush()` empties that buffer at the end of the run.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* One-shot removal, for text that is not part of the live stream.
|
|
25
|
+
*
|
|
26
|
+
* A run's error summary is stored in the database and rendered in the interface
|
|
27
|
+
* without ever passing through the stream, so it needs its own pass. Using the
|
|
28
|
+
* live `Redactor` instance here would corrupt its buffer mid-run.
|
|
29
|
+
*/
|
|
30
|
+
export function scrub(text, values) {
|
|
31
|
+
let out = text;
|
|
32
|
+
for (const value of [...new Set(values.filter((v) => v.length >= MIN_LENGTH))].sort((a, b) => b.length - a.length)) {
|
|
33
|
+
out = out.split(value).join(MARKER);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
export class Redactor {
|
|
38
|
+
values;
|
|
39
|
+
longest;
|
|
40
|
+
held = "";
|
|
41
|
+
constructor(values) {
|
|
42
|
+
// Longest first: replacing "token" before "token-suffix" would leave the
|
|
43
|
+
// suffix behind in the log.
|
|
44
|
+
this.values = [...new Set(values.filter((v) => v.length >= MIN_LENGTH))].sort((a, b) => b.length - a.length);
|
|
45
|
+
this.longest = this.values.reduce((max, v) => Math.max(max, v.length), 0);
|
|
46
|
+
}
|
|
47
|
+
replaceAll(text) {
|
|
48
|
+
let out = text;
|
|
49
|
+
for (const value of this.values)
|
|
50
|
+
out = out.split(value).join(MARKER);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* How many trailing characters could still turn into a secret.
|
|
55
|
+
*
|
|
56
|
+
* Holding a fixed window of `longest - 1` would be correct but wasteful: with
|
|
57
|
+
* a 200-character API key in the vault, every chunk would stall its last 199
|
|
58
|
+
* characters until the next one arrived, and the live terminal would lag
|
|
59
|
+
* visibly. So we keep only what is genuinely ambiguous — the longest suffix
|
|
60
|
+
* that is a proper prefix of some secret — which is usually nothing at all.
|
|
61
|
+
*/
|
|
62
|
+
ambiguousTail(text) {
|
|
63
|
+
const max = Math.min(this.longest - 1, text.length);
|
|
64
|
+
for (let length = max; length > 0; length -= 1) {
|
|
65
|
+
const suffix = text.slice(text.length - length);
|
|
66
|
+
if (this.values.some((value) => value.startsWith(suffix)))
|
|
67
|
+
return length;
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
/** Takes a chunk, returns the part that is safe to write out. */
|
|
72
|
+
push(chunk) {
|
|
73
|
+
if (this.values.length === 0)
|
|
74
|
+
return chunk;
|
|
75
|
+
const combined = this.replaceAll(this.held + chunk);
|
|
76
|
+
const keep = this.ambiguousTail(combined);
|
|
77
|
+
this.held = combined.slice(combined.length - keep);
|
|
78
|
+
return keep === 0 ? combined : combined.slice(0, combined.length - keep);
|
|
79
|
+
}
|
|
80
|
+
/** Releases the tail. Call once, when the stream is over. */
|
|
81
|
+
flush() {
|
|
82
|
+
const rest = this.replaceAll(this.held);
|
|
83
|
+
this.held = "";
|
|
84
|
+
return rest;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/src/main.js
CHANGED
|
@@ -5,14 +5,18 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { runAddCommand } from "./cli/add.js";
|
|
8
|
+
import { runSecretCommand } from "./cli/secret.js";
|
|
8
9
|
import { ConfigStore } from "./config/store.js";
|
|
9
10
|
import { CacheStore } from "./db/cache.js";
|
|
10
11
|
import { openDatabase } from "./db/open.js";
|
|
11
12
|
import { RunStore } from "./db/runs.js";
|
|
13
|
+
import { SecretStore } from "./db/secrets.js";
|
|
12
14
|
import { buildApp } from "./server/app.js";
|
|
15
|
+
import { Vault } from "./secrets/vault.js";
|
|
13
16
|
import { makeInvoke } from "./sidecar/bridge.js";
|
|
14
17
|
import { LaneReader } from "./sidecar/lanes.js";
|
|
15
|
-
|
|
18
|
+
import { UsesReader } from "./sidecar/uses.js";
|
|
19
|
+
export const version = "0.2.0";
|
|
16
20
|
/** Assembles the server from a data folder. */
|
|
17
21
|
export async function createServerFromConfig(root) {
|
|
18
22
|
const config = new ConfigStore(join(root, "config.yml"));
|
|
@@ -20,19 +24,31 @@ export async function createServerFromConfig(root) {
|
|
|
20
24
|
if (!loaded.ok)
|
|
21
25
|
throw new Error(`Unreadable configuration: ${loaded.error}`);
|
|
22
26
|
const db = openDatabase(join(root, "laneyard.db"));
|
|
23
|
-
// No run can survive the shutdown of the process that carried
|
|
24
|
-
|
|
27
|
+
// No run that had begun can survive the shutdown of the process that carried
|
|
28
|
+
// it. Queued runs never began, so they stay queued for the next start.
|
|
29
|
+
new RunStore(db).interruptInFlight();
|
|
25
30
|
const cache = new CacheStore(db);
|
|
31
|
+
const vault = await Vault.open(root, new SecretStore(db));
|
|
26
32
|
const app = await buildApp({
|
|
27
33
|
config,
|
|
28
34
|
db,
|
|
29
35
|
root,
|
|
36
|
+
vault,
|
|
30
37
|
lanes: async (slug, workspacePath, fastlaneDir) => {
|
|
31
38
|
const resolved = await config.resolve(slug, workspacePath);
|
|
32
39
|
const reader = new LaneReader(cache, makeInvoke(resolved?.settings.runtime ?? "bundle"));
|
|
33
40
|
return reader.read(slug, workspacePath, fastlaneDir);
|
|
34
41
|
},
|
|
42
|
+
uses: async (slug, workspacePath, fastlaneDir) => {
|
|
43
|
+
const resolved = await config.resolve(slug, workspacePath);
|
|
44
|
+
const reader = new UsesReader(cache, makeInvoke(resolved?.settings.runtime ?? "bundle"));
|
|
45
|
+
return reader.read(slug, workspacePath, fastlaneDir);
|
|
46
|
+
},
|
|
35
47
|
});
|
|
48
|
+
// Anything left queued from the previous life starts moving again now. Without
|
|
49
|
+
// this, `wake()` would only ever be called from the trigger route, and three
|
|
50
|
+
// runs queued before a restart would wait for someone to trigger a fourth.
|
|
51
|
+
app.queue.wake();
|
|
36
52
|
return { app, db, config };
|
|
37
53
|
}
|
|
38
54
|
/** Real startup, outside tests. */
|
|
@@ -69,6 +85,8 @@ function invokedDirectly() {
|
|
|
69
85
|
const USAGE = `laneyard — a self-hosted web UI for fastlane
|
|
70
86
|
|
|
71
87
|
laneyard add adopt the project in the current directory
|
|
88
|
+
laneyard secret set NAME [--project <slug>]
|
|
89
|
+
store a secret, its value read from standard input
|
|
72
90
|
laneyard start the server
|
|
73
91
|
laneyard --version print the version
|
|
74
92
|
|
|
@@ -113,6 +131,17 @@ if (invokedDirectly()) {
|
|
|
113
131
|
process.exit(1);
|
|
114
132
|
}
|
|
115
133
|
}
|
|
134
|
+
// Above the catch-all below, which would otherwise reject it as unknown.
|
|
135
|
+
if (command === "secret") {
|
|
136
|
+
const home = homeDir();
|
|
137
|
+
await mkdir(home, { recursive: true });
|
|
138
|
+
process.exit(await runSecretCommand(home, rest, {
|
|
139
|
+
stdin: process.stdin,
|
|
140
|
+
interactive: process.stdin.isTTY === true,
|
|
141
|
+
out: (text) => process.stdout.write(text),
|
|
142
|
+
err: (text) => process.stderr.write(text),
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
116
145
|
if (command !== undefined) {
|
|
117
146
|
process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
|
|
118
147
|
process.exit(1);
|
|
@@ -2,6 +2,7 @@ import { rm } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { Workspace } from "../git/workspace.js";
|
|
4
4
|
import { summarizeFailure } from "../heuristics/error-summary.js";
|
|
5
|
+
import { Redactor, scrub } from "../logs/redact.js";
|
|
5
6
|
import { collectArtifacts } from "./artifacts.js";
|
|
6
7
|
import { LiveStepTracker } from "./live-steps.js";
|
|
7
8
|
import { startPty } from "./pty.js";
|
|
@@ -17,19 +18,47 @@ export async function executeRun(opts) {
|
|
|
17
18
|
const { runId, runs, logs } = opts;
|
|
18
19
|
const writer = await logs.open(runId);
|
|
19
20
|
const tracker = new LiveStepTracker();
|
|
21
|
+
const redactor = new Redactor(opts.maskedValues ?? []);
|
|
22
|
+
// Redaction happens here and nowhere else: this is the single point through
|
|
23
|
+
// which every byte of output passes on its way to the file, the step tracker
|
|
24
|
+
// and the browser. Filtering further downstream would mean filtering three
|
|
25
|
+
// times, and forgetting one of them eventually.
|
|
20
26
|
const emit = async (text) => {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
const safe = redactor.push(text);
|
|
28
|
+
if (safe === "")
|
|
29
|
+
return;
|
|
30
|
+
const offset = await writer.append(safe);
|
|
31
|
+
tracker.consume(safe, offset);
|
|
32
|
+
opts.onChunk(safe, offset);
|
|
24
33
|
};
|
|
25
|
-
const
|
|
34
|
+
const emitRest = async () => {
|
|
35
|
+
const rest = redactor.flush();
|
|
36
|
+
if (rest === "")
|
|
37
|
+
return;
|
|
38
|
+
const offset = await writer.append(rest);
|
|
39
|
+
tracker.consume(rest, offset);
|
|
40
|
+
opts.onChunk(rest, offset);
|
|
41
|
+
};
|
|
42
|
+
// The error summary is stored in the database and rendered in the interface
|
|
43
|
+
// without passing through the stream above, so it needs its own, one-shot pass.
|
|
44
|
+
const hide = (text) => scrub(text, opts.maskedValues ?? []);
|
|
45
|
+
// Shared by every early exit, so flushing and closing the log writer cannot
|
|
46
|
+
// drift between the failure path and the two cancellation checkpoints below.
|
|
47
|
+
const finishAs = async (status, message) => {
|
|
26
48
|
await emit(`\n${message}\n`);
|
|
49
|
+
await emitRest();
|
|
27
50
|
await writer.close();
|
|
28
|
-
runs.finish(runId, { status
|
|
29
|
-
return { status
|
|
51
|
+
runs.finish(runId, { status, exitCode: null, errorSummary: hide(message) });
|
|
52
|
+
return { status };
|
|
30
53
|
};
|
|
54
|
+
const fail = (message) => finishAs("failed", message);
|
|
31
55
|
// --- Preparation --------------------------------------------------------
|
|
32
56
|
runs.setStatus(runId, "preparing");
|
|
57
|
+
// Cancelled before it even started: no point cloning or fetching a
|
|
58
|
+
// workspace nobody wants built anymore.
|
|
59
|
+
if (opts.signal?.aborted) {
|
|
60
|
+
return finishAs("cancelled", "Cancelled");
|
|
61
|
+
}
|
|
33
62
|
const workspace = new Workspace(opts.workspacePath, opts.gitUrl, opts.gitAuth);
|
|
34
63
|
let commitSha;
|
|
35
64
|
try {
|
|
@@ -50,6 +79,10 @@ export async function executeRun(opts) {
|
|
|
50
79
|
catch (cause) {
|
|
51
80
|
return fail(`Unreadable project settings: ${cause.message}`);
|
|
52
81
|
}
|
|
82
|
+
// Cancelled during preparation: fastlane never gets to start.
|
|
83
|
+
if (opts.signal?.aborted) {
|
|
84
|
+
return finishAs("cancelled", "Cancelled");
|
|
85
|
+
}
|
|
53
86
|
// --- Execution -----------------------------------------------------------
|
|
54
87
|
const useBundle = settings.runtime === "bundle";
|
|
55
88
|
const reportPath = join(opts.workspacePath, settings.fastlane_dir, "report.xml");
|
|
@@ -65,16 +98,20 @@ export async function executeRun(opts) {
|
|
|
65
98
|
cwd: opts.workspacePath,
|
|
66
99
|
env: {
|
|
67
100
|
...opts.env,
|
|
68
|
-
|
|
101
|
+
...(opts.secrets ?? {}),
|
|
102
|
+
// Order matters: secrets come after opts.env so a stored secret wins over
|
|
103
|
+
// 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.
|
|
69
105
|
CI: "true",
|
|
70
106
|
FASTLANE_SKIP_UPDATE_CHECK: "1",
|
|
71
107
|
FORCE_COLOR: "1",
|
|
72
108
|
},
|
|
73
109
|
onData: (chunk) => void emit(chunk),
|
|
74
110
|
timeoutMs: settings.timeout_minutes * 60_000,
|
|
111
|
+
signal: opts.signal,
|
|
75
112
|
});
|
|
76
113
|
const outcome = await done;
|
|
77
|
-
await
|
|
114
|
+
await emitRest();
|
|
78
115
|
// --- Timeline -------------------------------------------------------------
|
|
79
116
|
// Everything that follows is after-sales service: the timeline and the
|
|
80
117
|
// artifacts embellish a run that's already finished. A database that
|
|
@@ -86,14 +123,27 @@ export async function executeRun(opts) {
|
|
|
86
123
|
}
|
|
87
124
|
catch (cause) {
|
|
88
125
|
await emit(`\nIncomplete timeline or artifacts: ${cause.message}\n`);
|
|
126
|
+
await emitRest();
|
|
89
127
|
}
|
|
128
|
+
await writer.close();
|
|
90
129
|
if (outcome.exitCode === 0 && !outcome.timedOut) {
|
|
91
130
|
runs.finish(runId, { status: "success", exitCode: 0, errorSummary: null });
|
|
92
131
|
return { status: "success" };
|
|
93
132
|
}
|
|
133
|
+
if (opts.signal?.aborted) {
|
|
134
|
+
// Checked ahead of the failure case: the SIGINT/SIGKILL escalation leaves
|
|
135
|
+
// fastlane with a nonzero exit code, which would otherwise read as a crash
|
|
136
|
+
// rather than the cancellation it actually was.
|
|
137
|
+
runs.finish(runId, {
|
|
138
|
+
status: "cancelled",
|
|
139
|
+
exitCode: outcome.exitCode,
|
|
140
|
+
errorSummary: "Cancelled",
|
|
141
|
+
});
|
|
142
|
+
return { status: "cancelled" };
|
|
143
|
+
}
|
|
94
144
|
const summary = outcome.timedOut
|
|
95
|
-
? `Run interrupted after ${settings.timeout_minutes} minutes`
|
|
96
|
-
: summarizeFailure(await logs.read(runId), outcome.exitCode);
|
|
145
|
+
? hide(`Run interrupted after ${settings.timeout_minutes} minutes`)
|
|
146
|
+
: hide(summarizeFailure(await logs.read(runId), outcome.exitCode));
|
|
97
147
|
runs.finish(runId, { status: "failed", exitCode: outcome.exitCode, errorSummary: summary });
|
|
98
148
|
return { status: "failed" };
|
|
99
149
|
async function recordOutcome() {
|