withruntime 0.33.0 → 0.33.2
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/COMPATIBILITY.md +2 -2
- package/dist/ci/index.d.ts +3 -0
- package/dist/ci/index.js +3 -0
- package/dist/ci-cli.d.ts +1 -1
- package/dist/ci-cli.js +79 -1
- package/dist/ci-import.d.ts +23 -0
- package/dist/ci-import.js +565 -0
- package/dist/keys.js +8 -8
- package/dist/login.js +8 -8
- package/dist/products/ci.d.ts +12 -0
- package/dist/products/ci.js +18 -0
- package/dist/sealing.d.ts +6 -0
- package/dist/sealing.js +14 -0
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +1 -1
- package/package.json +3 -3
package/COMPATIBILITY.md
CHANGED
|
@@ -329,8 +329,8 @@ share the file path and `compat.provider` resource label.
|
|
|
329
329
|
## Evidence that can be repeated locally
|
|
330
330
|
|
|
331
331
|
Run `bun test tests/compat` from the SDK package and run `bun run check-types`.
|
|
332
|
-
The
|
|
333
|
-
`tests/compat/consumers/workflows.ts
|
|
332
|
+
The type check (`tests/compat/consumers/tsconfig.json`) compiles unchanged typed consumer bodies from
|
|
333
|
+
`tests/compat/consumers/workflows.ts` against all ten TypeScript adapters,
|
|
334
334
|
including E2B's interpreter. Run
|
|
335
335
|
`bun tests/compat/check-consumers.ts <verified-cache>` to compile the
|
|
336
336
|
same bodies against the pinned upstream declarations; the script checks each
|
package/dist/ci/index.d.ts
CHANGED
|
@@ -8,3 +8,6 @@ import { Runtime, type RuntimeOptions } from "../client.js";
|
|
|
8
8
|
export type * from "../products/ci.js";
|
|
9
9
|
/** `runtime.ci` of a client made with `options`. */
|
|
10
10
|
export declare function ci(options?: RuntimeOptions): Runtime["ci"];
|
|
11
|
+
/** GitHub Actions workflows and a CircleCI config as a `runtime.yml`, with
|
|
12
|
+
* notes on what was not carried over (`runtime ci import`). */
|
|
13
|
+
export { importCi, type CiImport, type CiSource } from "../ci-import.js";
|
package/dist/ci/index.js
CHANGED
|
@@ -9,3 +9,6 @@ import { Runtime } from "../client.js";
|
|
|
9
9
|
export function ci(options) {
|
|
10
10
|
return new Runtime(options).ci;
|
|
11
11
|
}
|
|
12
|
+
/** GitHub Actions workflows and a CircleCI config as a `runtime.yml`, with
|
|
13
|
+
* notes on what was not carried over (`runtime ci import`). */
|
|
14
|
+
export { importCi } from "../ci-import.js";
|
package/dist/ci-cli.d.ts
CHANGED
|
@@ -5,6 +5,6 @@ type Out = {
|
|
|
5
5
|
write: (text: string) => void;
|
|
6
6
|
error: (text: string) => void;
|
|
7
7
|
};
|
|
8
|
-
export declare const CI_HELP = "runtime ci <command>\n\n CI/CD checks each push and pull request of a connected GitHub repository in\n sandboxes, as the steps its config file names. A fork's pull request waits\n until a writer re-runs it on GitHub. In a private beta: outside it, each\n command says how to ask to join.\n\n connect <owner/name> [--branch main] [--config runtime.yml] [--installation <id>]\n [--trust <app-slug>[bot]]... [--name <name>]\n Connect a repository; prints its id. Takes a full key.\n --trust runs that App's pushes as its own from the start\n repos [ls] [--all] Connected repositories, with the Apps each trusts;\n --all: disconnected ones too\n repos trust <repoId | owner/name> <app-slug>[bot]...\n Run that App's pushes as the repository's own code,\n keeping the Apps it trusts already\n repos untrust <repoId | owner/name> <app-slug>[bot]...\n Stop trusting those Apps, keeping the rest\n disconnect <repoId | owner/name> Stop checking it; its checks still going are cancelled\n ls [--repository <repoId>] [--branch <name>] [--commit <sha>] [--pr <number>]\n [--state <state>]... [--limit 20] Checks, newest first, with their jobs by state\n get <checkId> One check and its jobs in plan order\n logs <jobId> A job's output, its last 256 KiB, and its sandboxes\n tests <repoId> [--quarantined] Its test files: time, recent outcomes, quarantine\n flakes [--repository <repoId>] [--all] Flaky tests: each quarantined (or still gating), its\n owner and 48-hour deadline; --all: ended ones too\n flake <flakeId> One flaky test: its proof, owner, and recent runs\n flake prove <flakeId> Its 100 proof runs again at main's next commit\n flake hand <flakeId> <login | org/team> Hand it to someone else\n flake delete <flakeId> --reason <why> Say why its test is deleted; it ends once main\n no longer holds the file\n\n A failed job's sandbox may be kept paused: `runtime sandboxes wake <id>`, then\n `runtime sandboxes shell <id>` to look around where it failed.\n\nExamples\n runtime ci connect acme/web --config .runtime/ci.yml\n runtime ci repos trust acme/web acme-bot[bot]\n runtime ci ls --commit $(git rev-parse HEAD)\n runtime ci logs <jobId>\n";
|
|
8
|
+
export declare const CI_HELP = "runtime ci <command>\n\n CI/CD checks each push and pull request of a connected GitHub repository in\n sandboxes, as the steps its config file names. A fork's pull request waits\n until a writer re-runs it on GitHub. In a private beta: outside it, each\n command says how to ask to join.\n\n import [dir] [--write] [--force] This folder's GitHub Actions workflows and CircleCI\n config as a runtime.yml: printed, or written with\n --write; what isn't carried over is noted at its top.\n Needs no key and sends nothing\n connect <owner/name> [--branch main] [--config runtime.yml] [--installation <id>]\n [--trust <app-slug>[bot]]... [--name <name>]\n Connect a repository; prints its id. Takes a full key.\n --trust runs that App's pushes as its own from the start\n repos [ls] [--all] Connected repositories, with the Apps each trusts;\n --all: disconnected ones too\n repos trust <repoId | owner/name> <app-slug>[bot]...\n Run that App's pushes as the repository's own code,\n keeping the Apps it trusts already\n repos untrust <repoId | owner/name> <app-slug>[bot]...\n Stop trusting those Apps, keeping the rest\n repos undo <repoId | owner/name> on|off When main's check fails, find the first commit that\n fails it and open a pull request reverting it, which\n lands once its check passes. Off until turned on;\n takes an owner's or admin's full key\n disconnect <repoId | owner/name> Stop checking it; its checks still going are cancelled\n ls [--repository <repoId>] [--branch <name>] [--commit <sha>] [--pr <number>]\n [--state <state>]... [--limit 20] Checks, newest first, with their jobs by state\n get <checkId> One check and its jobs in plan order\n cancel <checkId> Cancel a queued or running check: its jobs end,\n its machines are let go, GitHub is told\n logs <jobId> A job's output, its last 256 KiB, and its sandboxes\n tests <repoId> [--quarantined] Its test files: time, recent outcomes, quarantine\n flakes [--repository <repoId>] [--all] Flaky tests: each quarantined (or still gating), its\n owner and 48-hour deadline; --all: ended ones too\n flake <flakeId> One flaky test: its proof, owner, and recent runs\n flake prove <flakeId> Its 100 proof runs again at main's next commit\n flake hand <flakeId> <login | org/team> Hand it to someone else\n flake delete <flakeId> --reason <why> Say why its test is deleted; it ends once main\n no longer holds the file\n\n A failed job's sandbox may be kept paused: `runtime sandboxes wake <id>`, then\n `runtime sandboxes shell <id>` to look around where it failed.\n\nExamples\n runtime ci import --write\n runtime ci connect acme/web --config .runtime/ci.yml\n runtime ci repos trust acme/web acme-bot[bot]\n runtime ci repos undo acme/web on\n runtime ci ls --commit $(git rev-parse HEAD)\n runtime ci logs <jobId>\n";
|
|
9
9
|
export declare function ciCommand(argv: string[], out: Out, client: () => Promise<Runtime>, kit: CliKit): Promise<number>;
|
|
10
10
|
export {};
|
package/dist/ci-cli.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { importCi } from "./ci-import.js";
|
|
1
4
|
import { me, named } from "./cli-name.js";
|
|
2
5
|
import { ci, } from "./products/ci.js";
|
|
3
6
|
export const CI_HELP = `runtime ci <command>
|
|
@@ -7,6 +10,10 @@ export const CI_HELP = `runtime ci <command>
|
|
|
7
10
|
until a writer re-runs it on GitHub. In a private beta: outside it, each
|
|
8
11
|
command says how to ask to join.
|
|
9
12
|
|
|
13
|
+
import [dir] [--write] [--force] This folder's GitHub Actions workflows and CircleCI
|
|
14
|
+
config as a runtime.yml: printed, or written with
|
|
15
|
+
--write; what isn't carried over is noted at its top.
|
|
16
|
+
Needs no key and sends nothing
|
|
10
17
|
connect <owner/name> [--branch main] [--config runtime.yml] [--installation <id>]
|
|
11
18
|
[--trust <app-slug>[bot]]... [--name <name>]
|
|
12
19
|
Connect a repository; prints its id. Takes a full key.
|
|
@@ -18,10 +25,16 @@ export const CI_HELP = `runtime ci <command>
|
|
|
18
25
|
keeping the Apps it trusts already
|
|
19
26
|
repos untrust <repoId | owner/name> <app-slug>[bot]...
|
|
20
27
|
Stop trusting those Apps, keeping the rest
|
|
28
|
+
repos undo <repoId | owner/name> on|off When main's check fails, find the first commit that
|
|
29
|
+
fails it and open a pull request reverting it, which
|
|
30
|
+
lands once its check passes. Off until turned on;
|
|
31
|
+
takes an owner's or admin's full key
|
|
21
32
|
disconnect <repoId | owner/name> Stop checking it; its checks still going are cancelled
|
|
22
33
|
ls [--repository <repoId>] [--branch <name>] [--commit <sha>] [--pr <number>]
|
|
23
34
|
[--state <state>]... [--limit 20] Checks, newest first, with their jobs by state
|
|
24
35
|
get <checkId> One check and its jobs in plan order
|
|
36
|
+
cancel <checkId> Cancel a queued or running check: its jobs end,
|
|
37
|
+
its machines are let go, GitHub is told
|
|
25
38
|
logs <jobId> A job's output, its last 256 KiB, and its sandboxes
|
|
26
39
|
tests <repoId> [--quarantined] Its test files: time, recent outcomes, quarantine
|
|
27
40
|
flakes [--repository <repoId>] [--all] Flaky tests: each quarantined (or still gating), its
|
|
@@ -36,8 +49,10 @@ export const CI_HELP = `runtime ci <command>
|
|
|
36
49
|
\`runtime sandboxes shell <id>\` to look around where it failed.
|
|
37
50
|
|
|
38
51
|
Examples
|
|
52
|
+
runtime ci import --write
|
|
39
53
|
runtime ci connect acme/web --config .runtime/ci.yml
|
|
40
54
|
runtime ci repos trust acme/web acme-bot[bot]
|
|
55
|
+
runtime ci repos undo acme/web on
|
|
41
56
|
runtime ci ls --commit $(git rev-parse HEAD)
|
|
42
57
|
runtime ci logs <jobId>
|
|
43
58
|
`;
|
|
@@ -64,6 +79,46 @@ const STATES = ["queued", "running", "passed", "failed", "cancelled"];
|
|
|
64
79
|
const ownerOf = (f) => f.owner === null
|
|
65
80
|
? "nobody found"
|
|
66
81
|
: `@${f.owner.login}${f.owner.pullRequest === null ? "" : ` (#${f.owner.pullRequest})`}`;
|
|
82
|
+
/** `runtime ci import [dir] [--write] [--force]`: the folder's GitHub Actions
|
|
83
|
+
* workflows and CircleCI config as a `runtime.yml`, printed or written. */
|
|
84
|
+
async function importCommand(argv, out, kit) {
|
|
85
|
+
const args = kit.parse(argv, ["write", "force"], []);
|
|
86
|
+
const root = args.positional[0] ?? ".";
|
|
87
|
+
const files = [];
|
|
88
|
+
const workflows = join(root, ".github", "workflows");
|
|
89
|
+
if (existsSync(workflows))
|
|
90
|
+
for (const name of readdirSync(workflows).sort())
|
|
91
|
+
if (/\.ya?ml$/.test(name))
|
|
92
|
+
files.push({
|
|
93
|
+
path: `.github/workflows/${name}`,
|
|
94
|
+
text: readFileSync(join(workflows, name), "utf8"),
|
|
95
|
+
});
|
|
96
|
+
const circle = join(root, ".circleci", "config.yml");
|
|
97
|
+
if (existsSync(circle))
|
|
98
|
+
files.push({ path: ".circleci/config.yml", text: readFileSync(circle, "utf8") });
|
|
99
|
+
if (!files.length)
|
|
100
|
+
throw kit.usage(`${root} has neither .github/workflows/*.yml nor .circleci/config.yml to import.`);
|
|
101
|
+
const made = importCi(files);
|
|
102
|
+
if (!made.yaml) {
|
|
103
|
+
out.error(made.notes.join("\n"));
|
|
104
|
+
throw kit.usage("No job of these runs on a push or a pull request: nothing to write.");
|
|
105
|
+
}
|
|
106
|
+
if (!kit.has(args, "write")) {
|
|
107
|
+
out.write(out.json ? JSON.stringify({ ...made, read: files.map((f) => f.path) }) : made.yaml.trimEnd());
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
const target = join(root, "runtime.yml");
|
|
111
|
+
if (existsSync(target) && !kit.has(args, "force"))
|
|
112
|
+
throw kit.usage(`${target} is there already: --force writes over it.`);
|
|
113
|
+
writeFileSync(target, made.yaml);
|
|
114
|
+
const notes = made.notes.length
|
|
115
|
+
? `, ${made.notes.length} notes on what was not carried over at its top`
|
|
116
|
+
: "";
|
|
117
|
+
out.write(out.json
|
|
118
|
+
? JSON.stringify({ ...made, read: files.map((f) => f.path), written: target })
|
|
119
|
+
: `${target}: ${made.steps.length} steps from ${files.length === 1 ? "1 file" : `${files.length} files`}${notes}. Push it, then \`${me} ci connect <owner/name>\`.`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
67
122
|
export async function ciCommand(argv, out, client, kit) {
|
|
68
123
|
const { parse, flag, has, need, table, usage, unknown } = kit;
|
|
69
124
|
const [verb, ...rest] = argv;
|
|
@@ -72,6 +127,9 @@ export async function ciCommand(argv, out, client, kit) {
|
|
|
72
127
|
print(named(CI_HELP), { usage: named(CI_HELP) });
|
|
73
128
|
return 0;
|
|
74
129
|
}
|
|
130
|
+
// Reads and writes this folder's files only: no key, nothing sent.
|
|
131
|
+
if (verb === "import")
|
|
132
|
+
return importCommand(rest, out, kit);
|
|
75
133
|
const api = ci((await client()).transport);
|
|
76
134
|
const whole = (value, what) => {
|
|
77
135
|
if (value === undefined)
|
|
@@ -128,6 +186,18 @@ export async function ciCommand(argv, out, client, kit) {
|
|
|
128
186
|
print(`${repo.repository} trusts ${trusted(repo)}.`, repo);
|
|
129
187
|
return 0;
|
|
130
188
|
}
|
|
189
|
+
if (action === "undo") {
|
|
190
|
+
const id = need(more[0], "the repository's id or owner/name");
|
|
191
|
+
const say = need(more[1], "on or off");
|
|
192
|
+
if (say !== "on" && say !== "off")
|
|
193
|
+
throw usage(`${say} is neither on nor off: ${me} ci repos undo ${id} on`);
|
|
194
|
+
const found = await api.repositories.find(id);
|
|
195
|
+
const repo = await api.repositories.setUndo(found?.id ?? id, say === "on");
|
|
196
|
+
print(repo.undoCulprits
|
|
197
|
+
? `${repo.repository}: when ${repo.productionBranch}'s check fails, its culprit is found and a pull request reverting it opens.`
|
|
198
|
+
: `${repo.repository}: a failing ${repo.productionBranch} is reported, never undone.`, repo);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
131
201
|
if (action === "connect")
|
|
132
202
|
return ciCommand(["connect", ...more], out, client, kit);
|
|
133
203
|
if (action === "disconnect" || action === "rm")
|
|
@@ -139,6 +209,7 @@ export async function ciCommand(argv, out, client, kit) {
|
|
|
139
209
|
"connect",
|
|
140
210
|
"trust",
|
|
141
211
|
"untrust",
|
|
212
|
+
"undo",
|
|
142
213
|
"disconnect",
|
|
143
214
|
]);
|
|
144
215
|
const args = parse(listing, ["all"], []);
|
|
@@ -148,13 +219,14 @@ export async function ciCommand(argv, out, client, kit) {
|
|
|
148
219
|
})).toArray(Infinity);
|
|
149
220
|
print(all.length
|
|
150
221
|
? table([
|
|
151
|
-
["ID", "REPOSITORY", "BRANCH", "CONFIG", "TRUSTED APPS", "STATE"],
|
|
222
|
+
["ID", "REPOSITORY", "BRANCH", "CONFIG", "TRUSTED APPS", "UNDO", "STATE"],
|
|
152
223
|
...all.map((r) => [
|
|
153
224
|
r.id,
|
|
154
225
|
r.repository,
|
|
155
226
|
r.productionBranch,
|
|
156
227
|
r.configPath,
|
|
157
228
|
trusted(r),
|
|
229
|
+
r.undoCulprits ? "on" : "off",
|
|
158
230
|
r.state,
|
|
159
231
|
]),
|
|
160
232
|
])
|
|
@@ -220,6 +292,11 @@ export async function ciCommand(argv, out, client, kit) {
|
|
|
220
292
|
].join("\n"), check);
|
|
221
293
|
return 0;
|
|
222
294
|
}
|
|
295
|
+
case "cancel": {
|
|
296
|
+
const check = await api.checks.cancel(need(rest[0], "a check's id (runtime ci ls)"));
|
|
297
|
+
print(`${check.repository} ${check.commit.slice(0, 12)} ${where(check)}: ${check.state}${check.reason ? ` (${check.reason})` : ""}`, check);
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
223
300
|
case "logs":
|
|
224
301
|
case "job": {
|
|
225
302
|
const job = await api.jobs.get(need(rest[0], "a job's id (runtime ci get <checkId>)"));
|
|
@@ -323,6 +400,7 @@ export async function ciCommand(argv, out, client, kit) {
|
|
|
323
400
|
"disconnect",
|
|
324
401
|
"ls",
|
|
325
402
|
"get",
|
|
403
|
+
"cancel",
|
|
326
404
|
"logs",
|
|
327
405
|
"tests",
|
|
328
406
|
"flakes",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type CiSource = {
|
|
2
|
+
path: string;
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
export type CiImport = {
|
|
6
|
+
/** The `runtime.yml`, its notes on top as comments; `null` when no job runs on a push or pull request. */
|
|
7
|
+
yaml: string | null;
|
|
8
|
+
/** What was not carried over, or carried differently, one line each. */
|
|
9
|
+
notes: string[];
|
|
10
|
+
/** The steps written, by name. */
|
|
11
|
+
steps: string[];
|
|
12
|
+
};
|
|
13
|
+
/** Every combination of a GitHub `strategy.matrix`, its `exclude` and `include` applied. */
|
|
14
|
+
export declare function matrixCells(matrix: Record<string, unknown> | undefined): Record<string, string>[];
|
|
15
|
+
/** Files a cache key hashes: GitHub's `hashFiles('a', 'b')` or CircleCI's `{{ checksum "a" }}`. */
|
|
16
|
+
export declare function cacheFiles(key: string): string[];
|
|
17
|
+
/** Whether a line installs the project's own dependencies and nothing else:
|
|
18
|
+
* `npm ci` or `pip install -r requirements.txt`, never `npm install pino@9`,
|
|
19
|
+
* which one matrix cell asks and another doesn't. */
|
|
20
|
+
export declare function installsOnly(line: string): boolean;
|
|
21
|
+
/** `runtime.yml` from GitHub Actions workflows (`.github/workflows/*.yml`)
|
|
22
|
+
* and a CircleCI config (`.circleci/config.yml`), each given with its path. */
|
|
23
|
+
export declare function importCi(files: CiSource[]): CiImport;
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { parse, stringify } from "yaml";
|
|
2
|
+
/** The most a step's `timeout:` may say, in seconds: GitHub's own 6 hours. */
|
|
3
|
+
const MOST_TIMEOUT_S = 6 * 3600;
|
|
4
|
+
const MOST_STEPS = 64;
|
|
5
|
+
const MOST_CACHE = 32;
|
|
6
|
+
// A project's own install: run once in setup and kept in the warm machine,
|
|
7
|
+
// so a check runs only its tests.
|
|
8
|
+
const INSTALL = /^(npm (ci|install|i)|yarn( install)?|pnpm (install|i)|bun (install|i)|pip3? install|python3? -m pip install|poetry install|uv sync|pipenv install|go mod download|cargo fetch|bundle install|composer install|mix deps\.get)(?=\s|$)/;
|
|
9
|
+
// Actions whose work a Runtime machine already did: the commit is checked
|
|
10
|
+
// out, and Node, Bun, Python, git and a C compiler are on it.
|
|
11
|
+
const DONE = /^actions\/checkout@/;
|
|
12
|
+
const TOOLS = /^(actions\/setup-(node|python|go|java|dotnet)|oven-sh\/setup-bun|pnpm\/action-setup|dtolnay\/rust-toolchain|actions-rs\/toolchain|ruby\/setup-ruby|astral-sh\/setup-uv|denoland\/setup-deno)@/;
|
|
13
|
+
const CACHE = /^actions\/cache(\/restore|\/save)?@/;
|
|
14
|
+
const HAS = "a machine has Node, Bun, Python, git and a C compiler; install anything else in setup";
|
|
15
|
+
const stepName = (raw) => raw
|
|
16
|
+
.replace(/[^A-Za-z0-9 _.:-]+/g, " ")
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/^[^A-Za-z0-9]+/, "")
|
|
20
|
+
.slice(0, 63)
|
|
21
|
+
.trim() || "job";
|
|
22
|
+
const envName = (k) => /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(k) && !k.startsWith("RUNTIME_");
|
|
23
|
+
const quoteSh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
24
|
+
/** A value as one shell word: its literal text single-quoted, each variable
|
|
25
|
+
* `expand` put in (`${RUNTIME_CI_COMMIT}` for `${{ github.sha }}`) in double
|
|
26
|
+
* quotes, so the shell fills it in and nothing else in the value is read. */
|
|
27
|
+
const wordSh = (parts) => parts.map((p) => (typeof p === "string" ? quoteSh(p) : `"\${${p.v}}"`)).join("") || "''";
|
|
28
|
+
const asText = (v) => v === null || v === undefined
|
|
29
|
+
? ""
|
|
30
|
+
: typeof v === "string"
|
|
31
|
+
? v
|
|
32
|
+
: typeof v === "number" || typeof v === "boolean"
|
|
33
|
+
? `${v}`
|
|
34
|
+
: JSON.stringify(v);
|
|
35
|
+
/** An action's name with its pinned ref left off: `actions/cache@<sha>` is `actions/cache`. */
|
|
36
|
+
const action = (uses) => uses.split("@")[0];
|
|
37
|
+
/** Every combination of a GitHub `strategy.matrix`, its `exclude` and `include` applied. */
|
|
38
|
+
export function matrixCells(matrix) {
|
|
39
|
+
if (!matrix || typeof matrix !== "object")
|
|
40
|
+
return [{}];
|
|
41
|
+
const keys = Object.keys(matrix).filter((k) => k !== "include" && k !== "exclude");
|
|
42
|
+
let cells = keys.length ? [{}] : [];
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
const values = matrix[key];
|
|
45
|
+
if (!Array.isArray(values))
|
|
46
|
+
continue;
|
|
47
|
+
cells = cells.flatMap((c) => values.map((v) => ({ ...c, [key]: asText(v) })));
|
|
48
|
+
}
|
|
49
|
+
const fits = (cell, rule) => Object.entries(rule).every(([k, v]) => cell[k] === v);
|
|
50
|
+
const text = (rule) => Object.fromEntries(Object.entries(rule && typeof rule === "object" ? rule : {}).map(([k, v]) => [k, asText(v)]));
|
|
51
|
+
for (const rule of matrix.exclude ?? [])
|
|
52
|
+
cells = cells.filter((c) => !fits(c, text(rule)));
|
|
53
|
+
for (const rule of matrix.include ?? []) {
|
|
54
|
+
const add = text(rule);
|
|
55
|
+
const base = Object.fromEntries(Object.entries(add).filter(([k]) => keys.includes(k)));
|
|
56
|
+
const matched = Object.keys(base).length ? cells.filter((c) => fits(c, base)) : [];
|
|
57
|
+
if (matched.length)
|
|
58
|
+
for (const c of matched)
|
|
59
|
+
Object.assign(c, add);
|
|
60
|
+
else
|
|
61
|
+
cells.push(add);
|
|
62
|
+
}
|
|
63
|
+
return cells.length ? cells : [{}];
|
|
64
|
+
}
|
|
65
|
+
/** Files a cache key hashes: GitHub's `hashFiles('a', 'b')` or CircleCI's `{{ checksum "a" }}`. */
|
|
66
|
+
export function cacheFiles(key) {
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const m of key.matchAll(/hashFiles\(([^)]*)\)/g))
|
|
69
|
+
for (const f of m[1].matchAll(/['"]([^'"]+)['"]/g))
|
|
70
|
+
out.push(f[1]);
|
|
71
|
+
for (const m of key.matchAll(/checksum\s+"([^"]+)"/g))
|
|
72
|
+
out.push(m[1]);
|
|
73
|
+
return out.filter((f) => !f.includes("{{") && !f.startsWith("/") && !f.startsWith("~"));
|
|
74
|
+
}
|
|
75
|
+
/** Whether a line installs the project's own dependencies and nothing else:
|
|
76
|
+
* `npm ci` or `pip install -r requirements.txt`, never `npm install pino@9`,
|
|
77
|
+
* which one matrix cell asks and another doesn't. */
|
|
78
|
+
export function installsOnly(line) {
|
|
79
|
+
const t = line.trim();
|
|
80
|
+
const m = INSTALL.exec(t);
|
|
81
|
+
if (!m || /[|;&<>$`]/.test(t))
|
|
82
|
+
return false;
|
|
83
|
+
const words = t.slice(m[0].length).trim().split(/\s+/).filter(Boolean);
|
|
84
|
+
for (let i = 0; i < words.length; i++) {
|
|
85
|
+
const w = words[i];
|
|
86
|
+
if (["-r", "--requirement", "-e", "--editable", "-c"].includes(w))
|
|
87
|
+
i++;
|
|
88
|
+
else if (!w.startsWith("-") && w !== ".")
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
class Writer {
|
|
94
|
+
steps = [];
|
|
95
|
+
setup = [];
|
|
96
|
+
cache = new Set();
|
|
97
|
+
notes = [];
|
|
98
|
+
note(where, what) {
|
|
99
|
+
const line = `${where}: ${what}`.replace(/\s+/g, " ");
|
|
100
|
+
if (!this.notes.includes(line))
|
|
101
|
+
this.notes.push(line);
|
|
102
|
+
}
|
|
103
|
+
/** A step's lines, its installs moved to setup when they run there as
|
|
104
|
+
* they did in the step: `movable` (the step names no folder and no env of
|
|
105
|
+
* its own) and no line before it changes folder. Setup runs at the
|
|
106
|
+
* repository's root with no step's env, so `npm ci` under
|
|
107
|
+
* `working-directory: web`, or after `cd web`, stays in its step. */
|
|
108
|
+
lines(script, movable = true) {
|
|
109
|
+
const kept = [];
|
|
110
|
+
let moved = !movable;
|
|
111
|
+
for (const line of script.split("\n")) {
|
|
112
|
+
if (/(^|[;&|(]\s*)(cd|pushd)\b/.test(line.trim()))
|
|
113
|
+
moved = true;
|
|
114
|
+
if (!moved && installsOnly(line)) {
|
|
115
|
+
if (!this.setup.includes(line.trim()))
|
|
116
|
+
this.setup.push(line.trim());
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
kept.push(line.replace(/\s+$/, ""));
|
|
120
|
+
}
|
|
121
|
+
while (kept.length && !kept[kept.length - 1].trim())
|
|
122
|
+
kept.pop();
|
|
123
|
+
while (kept.length && !kept[0].trim())
|
|
124
|
+
kept.shift();
|
|
125
|
+
return kept;
|
|
126
|
+
}
|
|
127
|
+
unique(name) {
|
|
128
|
+
let n = stepName(name);
|
|
129
|
+
for (let i = 2; this.steps.some((s) => s.name === n); i++)
|
|
130
|
+
n = stepName(`${name} ${i}`);
|
|
131
|
+
return n;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** A job's steps as one script. Each step of GitHub's or CircleCI's ran in a
|
|
135
|
+
* shell of its own, so a `cd` or an `export` in one never reached the next:
|
|
136
|
+
* with more than one, each is a subshell. Undefined when none runs anything. */
|
|
137
|
+
function script(blocks) {
|
|
138
|
+
const runs = blocks.filter((b) => b.some((l) => l.trim() && !l.trim().startsWith("#")));
|
|
139
|
+
if (!runs.length)
|
|
140
|
+
return undefined;
|
|
141
|
+
const lines = blocks.flatMap((b) => runs.length > 1 && runs.includes(b) ? ["(", ...b.map((l) => ` ${l}`), ")"] : b);
|
|
142
|
+
return ["set -e", ...lines].join("\n");
|
|
143
|
+
}
|
|
144
|
+
/** `${{ ... }}` in a GitHub value: matrix cells, the commit and the branch
|
|
145
|
+
* filled in; a secret, or anything else only GitHub knows, leaves the value
|
|
146
|
+
* out (undefined), and `bad` is told what. */
|
|
147
|
+
function expand(value, cell, bad) {
|
|
148
|
+
const parts = expandParts(value, cell, bad);
|
|
149
|
+
return parts?.map((p) => (typeof p === "string" ? p : `\${${p.v}}`)).join("");
|
|
150
|
+
}
|
|
151
|
+
/** `expand`'s value as its literal parts and the variables put in for
|
|
152
|
+
* GitHub's values, for a value written as one shell word (`wordSh`). */
|
|
153
|
+
function expandParts(value, cell, bad) {
|
|
154
|
+
let ok = true;
|
|
155
|
+
const parts = [];
|
|
156
|
+
const text = (t) => {
|
|
157
|
+
if (!t)
|
|
158
|
+
return;
|
|
159
|
+
const last = parts[parts.length - 1];
|
|
160
|
+
if (typeof last === "string")
|
|
161
|
+
parts[parts.length - 1] = last + t;
|
|
162
|
+
else
|
|
163
|
+
parts.push(t);
|
|
164
|
+
};
|
|
165
|
+
let at = 0;
|
|
166
|
+
for (const m of value.matchAll(/\$\{\{([\s\S]*?)\}\}/g)) {
|
|
167
|
+
text(value.slice(at, m.index));
|
|
168
|
+
at = m.index + m[0].length;
|
|
169
|
+
const expr = m[1].trim();
|
|
170
|
+
const c = /^matrix\.([A-Za-z0-9_-]+)$/.exec(expr);
|
|
171
|
+
const e = /^env\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
|
|
172
|
+
if (c && cell[c[1]] !== undefined)
|
|
173
|
+
text(cell[c[1]]);
|
|
174
|
+
else if (expr === "github.sha")
|
|
175
|
+
parts.push({ v: "RUNTIME_CI_COMMIT" });
|
|
176
|
+
else if (expr === "github.ref_name" || expr === "github.head_ref")
|
|
177
|
+
parts.push({ v: "RUNTIME_CI_BRANCH" });
|
|
178
|
+
else if (expr === "github.workspace")
|
|
179
|
+
parts.push({ v: "PWD" });
|
|
180
|
+
else if (e)
|
|
181
|
+
parts.push({ v: e[1] });
|
|
182
|
+
else {
|
|
183
|
+
const s = /^secrets\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
|
|
184
|
+
bad(s ? `secret ${s[1]}` : m[0]);
|
|
185
|
+
ok = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
text(value.slice(at));
|
|
189
|
+
return ok ? parts : undefined;
|
|
190
|
+
}
|
|
191
|
+
/** A YAML mapping, or an empty one for anything else. */
|
|
192
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
193
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
194
|
+
/** A key that is one name or a list of them (`needs: a` or `needs: [a, b]`). */
|
|
195
|
+
const names = (v) => (typeof v === "string" ? [v] : list(v).map(asText));
|
|
196
|
+
const runsOnPush = (on) => {
|
|
197
|
+
const events = typeof on === "string" ? [on] : Array.isArray(on) ? on : Object.keys(obj(on));
|
|
198
|
+
return events.some((e) => e === "push" || e === "pull_request" || e === "pull_request_target");
|
|
199
|
+
};
|
|
200
|
+
function read(w, file) {
|
|
201
|
+
try {
|
|
202
|
+
return obj(parse(file.text));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
w.note(file.path, `not read: ${error.message.split("\n")[0]}`);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function fromActions(w, file) {
|
|
210
|
+
const doc = read(w, file);
|
|
211
|
+
if (!doc?.jobs)
|
|
212
|
+
return;
|
|
213
|
+
if (!runsOnPush(doc.on)) {
|
|
214
|
+
w.note(file.path, "skipped: it runs on neither push nor pull_request");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const top = obj(doc.env);
|
|
218
|
+
const named = new Map();
|
|
219
|
+
const made = [];
|
|
220
|
+
for (const [id, raw] of Object.entries(obj(doc.jobs))) {
|
|
221
|
+
const job = obj(raw);
|
|
222
|
+
const where = `${file.path} ${id}`;
|
|
223
|
+
if (job.uses) {
|
|
224
|
+
w.note(where, `skipped: a reusable workflow (${asText(job.uses)}); import that file instead`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const runsOn = asText(job["runs-on"]);
|
|
228
|
+
if (job.if)
|
|
229
|
+
w.note(where, `its if (${asText(job.if)}) is not carried: it runs on every check`);
|
|
230
|
+
const timeout = jobTimeout(w, where, job["timeout-minutes"]);
|
|
231
|
+
if (job["continue-on-error"])
|
|
232
|
+
w.note(where, "continue-on-error is not carried: a failure fails the check");
|
|
233
|
+
const dir = asText(obj(obj(job.defaults).run)["working-directory"]);
|
|
234
|
+
const cells = matrixCells(obj(obj(job.strategy).matrix));
|
|
235
|
+
const jobSteps = list(job.steps).map(obj);
|
|
236
|
+
// What the kept commands read: a matrix key only a left-out setup
|
|
237
|
+
// action read (a Node version, say) names no step.
|
|
238
|
+
const reads = JSON.stringify([
|
|
239
|
+
job.env,
|
|
240
|
+
job.services,
|
|
241
|
+
jobSteps.map((s) => [s.run, s.env, s["working-directory"]]),
|
|
242
|
+
]);
|
|
243
|
+
const steps = [];
|
|
244
|
+
const bodies = new Set();
|
|
245
|
+
for (const cell of cells) {
|
|
246
|
+
const os = expand(runsOn, cell, () => { }) ?? runsOn;
|
|
247
|
+
if (/macos|windows/i.test(os)) {
|
|
248
|
+
w.note(where, `${os} is left out: Runtime machines are Linux`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const bad = (what) => w.note(where, `${what} is not carried; set it in the step's env or a Runtime secret`);
|
|
252
|
+
const env = {};
|
|
253
|
+
for (const [k, v] of Object.entries({ ...top, ...obj(job.env) })) {
|
|
254
|
+
const value = expand(asText(v), cell, bad);
|
|
255
|
+
if (value !== undefined && envName(k))
|
|
256
|
+
env[k] = value;
|
|
257
|
+
}
|
|
258
|
+
const blocks = [];
|
|
259
|
+
for (const [name, raw] of Object.entries(obj(job.services))) {
|
|
260
|
+
const svc = obj(raw);
|
|
261
|
+
const image = expand(asText(typeof raw === "string" ? raw : svc.image), cell, bad);
|
|
262
|
+
if (!image)
|
|
263
|
+
continue;
|
|
264
|
+
const flags = Object.entries(obj(svc.env)).map(([k, v]) => `-e ${k}=${wordSh(expandParts(asText(v), cell, bad) ?? [])}`);
|
|
265
|
+
const ports = list(svc.ports).map(asText);
|
|
266
|
+
const moved = ports.some((p) => {
|
|
267
|
+
const [a, b] = p.split(":");
|
|
268
|
+
return b !== undefined && a !== b;
|
|
269
|
+
});
|
|
270
|
+
const net = moved ? ports.map((p) => `-p ${p}`) : ["--network host"];
|
|
271
|
+
blocks.push([
|
|
272
|
+
["docker run -d --name", stepName(name).replace(/ /g, "-"), ...net, ...flags, image].join(" "),
|
|
273
|
+
]);
|
|
274
|
+
w.note(where, `service ${name} starts with Docker in the step; wait for it as its health check did`);
|
|
275
|
+
}
|
|
276
|
+
for (const s of jobSteps) {
|
|
277
|
+
const uses = asText(s.uses);
|
|
278
|
+
if (uses) {
|
|
279
|
+
if (DONE.test(uses))
|
|
280
|
+
continue;
|
|
281
|
+
if (TOOLS.test(uses))
|
|
282
|
+
w.note(where, `${action(uses)} is not needed: ${HAS}`);
|
|
283
|
+
else if (CACHE.test(uses))
|
|
284
|
+
for (const f of cacheFiles(asText(obj(s.with).key)))
|
|
285
|
+
w.cache.add(f);
|
|
286
|
+
else {
|
|
287
|
+
w.note(where, `action ${action(uses)} is not carried; run its commands in the step`);
|
|
288
|
+
blocks.push([`# was: uses: ${action(uses)}`]);
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!s.run)
|
|
293
|
+
continue;
|
|
294
|
+
const said = asText(s.name ?? s.run).split("\n")[0];
|
|
295
|
+
if (s.if && /failure\(\)|cancelled\(\)/.test(asText(s.if))) {
|
|
296
|
+
w.note(where, `step "${said}" ran only on failure; left out`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (s.if)
|
|
300
|
+
w.note(where, `step "${said}": its if (${asText(s.if)}) is not carried`);
|
|
301
|
+
const run = expand(asText(s.run), cell, bad);
|
|
302
|
+
if (run === undefined) {
|
|
303
|
+
blocks.push([`# left out (it needs a secret or a GitHub value): ${said}`]);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const exports = [];
|
|
307
|
+
for (const [k, v] of Object.entries(obj(s.env))) {
|
|
308
|
+
const value = expandParts(asText(v), cell, bad);
|
|
309
|
+
if (value !== undefined && envName(k))
|
|
310
|
+
exports.push(`export ${k}=${wordSh(value)}`);
|
|
311
|
+
}
|
|
312
|
+
const wd = asText(s["working-directory"]) || dir;
|
|
313
|
+
const lines = w.lines(run, !wd && !exports.length);
|
|
314
|
+
if (!lines.length)
|
|
315
|
+
continue;
|
|
316
|
+
blocks.push([...(wd ? [`cd ${quoteSh(wd)}`] : []), ...exports, ...lines]);
|
|
317
|
+
}
|
|
318
|
+
const run = script(blocks);
|
|
319
|
+
if (!run) {
|
|
320
|
+
w.note(where, "has no command left to run here; not written");
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
// Cells that differ only in what a left-out action read run the same
|
|
324
|
+
// commands: one step runs them.
|
|
325
|
+
const body = JSON.stringify([run, env]);
|
|
326
|
+
if (bodies.has(body)) {
|
|
327
|
+
w.note(where, "matrix cells that differ only in a tool's version run once, on the machine's own");
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
bodies.add(body);
|
|
331
|
+
const label = Object.entries(cell)
|
|
332
|
+
.filter(([k]) => reads.includes(`matrix.${k}`))
|
|
333
|
+
.map(([, v]) => v)
|
|
334
|
+
.join(" ");
|
|
335
|
+
const title = asText(job.name || id).includes("${{") ? id : asText(job.name || id);
|
|
336
|
+
steps.push({
|
|
337
|
+
name: w.unique(label ? `${title} ${label}` : title),
|
|
338
|
+
run,
|
|
339
|
+
needs: [],
|
|
340
|
+
env,
|
|
341
|
+
...(timeout ? { timeout } : {}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
made.push({ steps, needs: names(job.needs) });
|
|
345
|
+
named.set(id, steps.map((s) => s.name));
|
|
346
|
+
}
|
|
347
|
+
for (const m of made)
|
|
348
|
+
for (const s of m.steps) {
|
|
349
|
+
s.needs = m.needs.flatMap((n) => named.get(n) ?? []);
|
|
350
|
+
w.steps.push(s);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** A GitHub job's `timeout-minutes` as a step's `timeout:` in seconds;
|
|
354
|
+
* undefined (the default, 6 hours as GitHub's) when it says none, or a value
|
|
355
|
+
* that is not a number, which is noted. */
|
|
356
|
+
function jobTimeout(w, where, raw) {
|
|
357
|
+
if (raw === undefined || raw === null || raw === "")
|
|
358
|
+
return undefined;
|
|
359
|
+
const minutes = Number(raw);
|
|
360
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
361
|
+
w.note(where, `timeout-minutes ${asText(raw)} is not carried; the step has the default 6 hours`);
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
const seconds = Math.ceil(minutes * 60);
|
|
365
|
+
if (seconds > MOST_TIMEOUT_S) {
|
|
366
|
+
w.note(where, `asks ${asText(raw)} minutes; a step here runs at most 360, as on GitHub`);
|
|
367
|
+
return MOST_TIMEOUT_S;
|
|
368
|
+
}
|
|
369
|
+
return seconds;
|
|
370
|
+
}
|
|
371
|
+
// CircleCI's resource classes by vCPUs: medium is 2, large 4, xlarge 8, 2xlarge 16.
|
|
372
|
+
const CIRCLE_MACHINES = {
|
|
373
|
+
small: "2cpu",
|
|
374
|
+
medium: "2cpu",
|
|
375
|
+
large: "4cpu",
|
|
376
|
+
xlarge: "8cpu",
|
|
377
|
+
"2xlarge": "16cpu",
|
|
378
|
+
"2xlarge+": "16cpu",
|
|
379
|
+
};
|
|
380
|
+
const KEPT_BY_CHECK = [
|
|
381
|
+
"store_artifacts",
|
|
382
|
+
"store_test_results",
|
|
383
|
+
"persist_to_workspace",
|
|
384
|
+
"attach_workspace",
|
|
385
|
+
];
|
|
386
|
+
function fromCircle(w, file) {
|
|
387
|
+
const doc = read(w, file);
|
|
388
|
+
if (!doc)
|
|
389
|
+
return;
|
|
390
|
+
const jobs = obj(doc.jobs);
|
|
391
|
+
const commands = obj(doc.commands);
|
|
392
|
+
if (doc.orbs)
|
|
393
|
+
w.note(file.path, `orbs (${Object.keys(obj(doc.orbs)).join(", ")}) are not carried; run their commands in the step`);
|
|
394
|
+
// The jobs each workflow runs, with what each requires; every job when no workflow says.
|
|
395
|
+
const runs = [];
|
|
396
|
+
const workflows = Object.entries(obj(doc.workflows))
|
|
397
|
+
.filter(([k, v]) => k !== "version" && obj(v).jobs)
|
|
398
|
+
.map(([k, v]) => [k, obj(v)]);
|
|
399
|
+
for (const [wf, flow] of workflows) {
|
|
400
|
+
if (flow.triggers) {
|
|
401
|
+
w.note(`${file.path} ${wf}`, "skipped: a scheduled workflow");
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
for (const entry of list(flow.jobs)) {
|
|
405
|
+
if (typeof entry === "string")
|
|
406
|
+
runs.push({ job: entry, name: entry, requires: [] });
|
|
407
|
+
else
|
|
408
|
+
for (const [job, raw] of Object.entries(obj(entry))) {
|
|
409
|
+
const opts = obj(raw);
|
|
410
|
+
if (opts.type === "approval") {
|
|
411
|
+
w.note(`${file.path} ${job}`, "an approval hold is not carried");
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (opts.matrix)
|
|
415
|
+
w.note(`${file.path} ${job}`, "its matrix is not carried; one step is written");
|
|
416
|
+
runs.push({ job, name: asText(opts.name ?? job), requires: names(opts.requires) });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!workflows.length)
|
|
421
|
+
for (const job of Object.keys(jobs))
|
|
422
|
+
runs.push({ job, name: job, requires: [] });
|
|
423
|
+
const named = new Map();
|
|
424
|
+
const pending = [];
|
|
425
|
+
for (const r of runs) {
|
|
426
|
+
if (named.has(r.name))
|
|
427
|
+
continue;
|
|
428
|
+
const where = `${file.path} ${r.job}`;
|
|
429
|
+
if (!jobs[r.job]) {
|
|
430
|
+
w.note(where, "is an orb's job; not carried");
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const job = obj(jobs[r.job]);
|
|
434
|
+
if (job.macos || job.windows) {
|
|
435
|
+
w.note(where, "skipped: Runtime machines are Linux");
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const env = {};
|
|
439
|
+
for (const [k, v] of Object.entries(obj(job.environment)))
|
|
440
|
+
if (envName(k))
|
|
441
|
+
env[k] = asText(v);
|
|
442
|
+
const blocks = [];
|
|
443
|
+
const docker = list(job.docker).map(obj);
|
|
444
|
+
if (docker[0]?.image)
|
|
445
|
+
w.note(where, `ran in ${asText(docker[0].image)}; here ${HAS}`);
|
|
446
|
+
for (const svc of docker.slice(1)) {
|
|
447
|
+
const image = asText(svc.image);
|
|
448
|
+
const flags = Object.entries(obj(svc.environment)).map(([k, v]) => `-e ${k}=${quoteSh(asText(v))}`);
|
|
449
|
+
const name = stepName(asText(svc.name) || (image.split(":")[0].split("/").pop() ?? "")).replace(/ /g, "-");
|
|
450
|
+
blocks.push([["docker run -d --name", name, "--network host", ...flags, image].join(" ")]);
|
|
451
|
+
w.note(where, `service ${image} starts with Docker in the step; wait for it before the tests`);
|
|
452
|
+
}
|
|
453
|
+
const dir = asText(job.working_directory);
|
|
454
|
+
const walk = (steps, depth) => {
|
|
455
|
+
for (const raw of steps) {
|
|
456
|
+
const [kind, opts] = typeof raw === "string"
|
|
457
|
+
? [raw, undefined]
|
|
458
|
+
: (Object.entries(obj(raw))[0] ?? ["", undefined]);
|
|
459
|
+
// The checkout is made; Docker runs on the machine itself.
|
|
460
|
+
if (kind === "checkout" || kind === "setup_remote_docker")
|
|
461
|
+
continue;
|
|
462
|
+
if (kind === "run") {
|
|
463
|
+
const o = obj(opts);
|
|
464
|
+
const cmd = asText(typeof opts === "string" ? opts : o.command);
|
|
465
|
+
if (o.when === "on_fail") {
|
|
466
|
+
w.note(where, `step "${asText(o.name ?? cmd.split("\n")[0])}" ran only on failure; left out`);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const exports = Object.entries(obj(o.environment))
|
|
470
|
+
.filter(([k]) => envName(k))
|
|
471
|
+
.map(([k, v]) => `export ${k}=${quoteSh(asText(v))}`);
|
|
472
|
+
// A step's working_directory is under the job's, which is where
|
|
473
|
+
// the checkout went: here, the repository's root.
|
|
474
|
+
const wd = asText(o.working_directory);
|
|
475
|
+
const cd = wd && !/^[~/$]/.test(wd) && wd !== "." ? [`cd ${quoteSh(wd)}`] : [];
|
|
476
|
+
const lines = w.lines(cmd, !cd.length && !exports.length);
|
|
477
|
+
if (!lines.length)
|
|
478
|
+
continue;
|
|
479
|
+
if (wd && !cd.length && wd !== "." && wd !== dir)
|
|
480
|
+
w.note(where, `working_directory ${wd} is not carried; the step runs at the repository's root`);
|
|
481
|
+
blocks.push([...cd, ...exports, ...lines]);
|
|
482
|
+
}
|
|
483
|
+
else if (kind === "restore_cache" || kind === "save_cache") {
|
|
484
|
+
const o = obj(opts);
|
|
485
|
+
for (const key of [o.key, ...list(o.keys)])
|
|
486
|
+
for (const f of cacheFiles(asText(key)))
|
|
487
|
+
w.cache.add(f);
|
|
488
|
+
}
|
|
489
|
+
else if (commands[kind] && depth < 4) {
|
|
490
|
+
if (opts)
|
|
491
|
+
w.note(where, `command ${kind}'s parameters are not filled in`);
|
|
492
|
+
walk(list(obj(commands[kind]).steps), depth + 1);
|
|
493
|
+
}
|
|
494
|
+
else if (KEPT_BY_CHECK.includes(kind)) {
|
|
495
|
+
w.note(where, `${kind} is not carried: a check keeps each job's log, and a failed job's sandbox`);
|
|
496
|
+
}
|
|
497
|
+
else if (kind) {
|
|
498
|
+
w.note(where, `${kind} (an orb's or unknown step) is not carried; run its commands in the step`);
|
|
499
|
+
blocks.push([`# was: ${kind}`]);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
walk(list(job.steps), 0);
|
|
504
|
+
const run = script(blocks);
|
|
505
|
+
if (!run) {
|
|
506
|
+
w.note(where, "has no command left to run here; not written");
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const machine = CIRCLE_MACHINES[asText(job.resource_class).replace(/^.*\./, "")];
|
|
510
|
+
const step = {
|
|
511
|
+
name: w.unique(r.name),
|
|
512
|
+
run,
|
|
513
|
+
needs: [],
|
|
514
|
+
env,
|
|
515
|
+
...(machine && machine !== "4cpu" ? { machine } : {}),
|
|
516
|
+
};
|
|
517
|
+
named.set(r.name, step.name);
|
|
518
|
+
pending.push({ step, requires: r.requires });
|
|
519
|
+
}
|
|
520
|
+
for (const p of pending) {
|
|
521
|
+
p.step.needs = p.requires.map((n) => named.get(n)).filter((n) => !!n);
|
|
522
|
+
w.steps.push(p.step);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/** `runtime.yml` from GitHub Actions workflows (`.github/workflows/*.yml`)
|
|
526
|
+
* and a CircleCI config (`.circleci/config.yml`), each given with its path. */
|
|
527
|
+
export function importCi(files) {
|
|
528
|
+
const w = new Writer();
|
|
529
|
+
for (const f of [...files].sort((a, b) => a.path.localeCompare(b.path)))
|
|
530
|
+
if (f.path.includes(".circleci/"))
|
|
531
|
+
fromCircle(w, f);
|
|
532
|
+
else
|
|
533
|
+
fromActions(w, f);
|
|
534
|
+
if (!w.steps.length)
|
|
535
|
+
return { yaml: null, notes: w.notes, steps: [] };
|
|
536
|
+
if (w.steps.length > MOST_STEPS) {
|
|
537
|
+
w.note("runtime.yml", `${w.steps.length} steps; a file holds ${MOST_STEPS}, the rest are left out`);
|
|
538
|
+
w.steps = w.steps.slice(0, MOST_STEPS);
|
|
539
|
+
}
|
|
540
|
+
const cache = [...w.cache].slice(0, MOST_CACHE);
|
|
541
|
+
const doc = {};
|
|
542
|
+
if (w.setup.length)
|
|
543
|
+
doc.setup = w.setup.join("\n");
|
|
544
|
+
if (cache.length)
|
|
545
|
+
doc.cache = cache;
|
|
546
|
+
doc.steps = w.steps.map((s) => ({
|
|
547
|
+
name: s.name,
|
|
548
|
+
run: s.run,
|
|
549
|
+
...(s.needs.length ? { needs: s.needs } : {}),
|
|
550
|
+
...(s.machine ? { machine: s.machine } : {}),
|
|
551
|
+
...(s.timeout ? { timeout: s.timeout } : {}),
|
|
552
|
+
...(Object.keys(s.env).length ? { env: s.env } : {}),
|
|
553
|
+
}));
|
|
554
|
+
const head = [
|
|
555
|
+
`# Written by \`runtime ci import\` from ${files.map((f) => f.path).join(", ")}.`,
|
|
556
|
+
...(w.notes.length
|
|
557
|
+
? ["# Not carried over, or carried differently:", ...w.notes.map((n) => `# - ${n}`)]
|
|
558
|
+
: []),
|
|
559
|
+
];
|
|
560
|
+
return {
|
|
561
|
+
yaml: `${head.join("\n")}\n${stringify(doc, { lineWidth: 0 })}`,
|
|
562
|
+
notes: w.notes,
|
|
563
|
+
steps: w.steps.map((s) => s.name),
|
|
564
|
+
};
|
|
565
|
+
}
|
package/dist/keys.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { privateDecrypt } from "node:crypto";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { connectionOrigins } from "./credentials.js";
|
|
@@ -7,6 +7,7 @@ import { RuntimeError } from "./errors.js";
|
|
|
7
7
|
import { named } from "./cli-name.js";
|
|
8
8
|
import { sandboxIdentityHeaders } from "./identity.js";
|
|
9
9
|
import { describeRoute, envFetch } from "./proxy.js";
|
|
10
|
+
import { sealing } from "./sealing.js";
|
|
10
11
|
const KEY = /^rtcloud_([a-f0-9-]{36})_[A-Za-z0-9_-]{43}$/;
|
|
11
12
|
/** How often to ask whether the owner has answered. The website answers
|
|
12
13
|
* "pending" to anything faster than every 4.5 seconds. */
|
|
@@ -79,16 +80,19 @@ export async function createKey(request, env, options = {}) {
|
|
|
79
80
|
async function post(path, body, key) {
|
|
80
81
|
const address = `${origins.auth}/api/connect/${path}`;
|
|
81
82
|
let response;
|
|
83
|
+
// The sandbox's identity first: its own lookup has its own deadline, and
|
|
84
|
+
// the call's 20 s start when the call does.
|
|
85
|
+
const identity = await sandboxIdentityHeaders();
|
|
82
86
|
try {
|
|
83
87
|
response = await fetcher(address, {
|
|
84
88
|
method: "POST",
|
|
85
89
|
redirect: "error",
|
|
86
|
-
signal: AbortSignal.timeout(20_000),
|
|
87
90
|
headers: {
|
|
88
91
|
"content-type": "application/json",
|
|
89
92
|
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
90
|
-
...
|
|
93
|
+
...identity,
|
|
91
94
|
},
|
|
95
|
+
signal: AbortSignal.timeout(20_000),
|
|
92
96
|
body: JSON.stringify(body),
|
|
93
97
|
});
|
|
94
98
|
}
|
|
@@ -101,11 +105,7 @@ export async function createKey(request, env, options = {}) {
|
|
|
101
105
|
throw failure(response.status === 429 ? "rate_limited" : "key_request_failed", typeof result.error === "string" ? result.error : "Runtime could not take this request.", response.status);
|
|
102
106
|
return result;
|
|
103
107
|
}
|
|
104
|
-
const pair = await
|
|
105
|
-
modulusLength: 2048,
|
|
106
|
-
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
107
|
-
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
108
|
-
});
|
|
108
|
+
const pair = await sealing.pair();
|
|
109
109
|
const started = await post("start", {
|
|
110
110
|
purpose: "key",
|
|
111
111
|
agentName: name,
|
package/dist/login.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { privateDecrypt } from "node:crypto";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { me, named } from "./cli-name.js";
|
|
@@ -7,6 +7,7 @@ import { Runtime } from "./client.js";
|
|
|
7
7
|
import { checkBrowserOrigins, connectionOrigins, connectionStore } from "./credentials.js";
|
|
8
8
|
import { sandboxIdentityHeaders } from "./identity.js";
|
|
9
9
|
import { describeRoute, envFetch } from "./proxy.js";
|
|
10
|
+
import { sealing } from "./sealing.js";
|
|
10
11
|
class ConnectionError extends Error {
|
|
11
12
|
status;
|
|
12
13
|
constructor(status, message) {
|
|
@@ -73,16 +74,19 @@ function connection(env, options) {
|
|
|
73
74
|
async function request(path, body, key) {
|
|
74
75
|
let response;
|
|
75
76
|
const address = `${origins.auth}/api/connect/${path}`;
|
|
77
|
+
// The sandbox's identity first: its own lookup has its own deadline, and
|
|
78
|
+
// the call's 20 s start when the call does.
|
|
79
|
+
const identity = await sandboxIdentityHeaders();
|
|
76
80
|
try {
|
|
77
81
|
response = await fetcher(address, {
|
|
78
82
|
method: "POST",
|
|
79
83
|
redirect: "error",
|
|
80
|
-
signal: AbortSignal.timeout(20_000),
|
|
81
84
|
headers: {
|
|
82
85
|
"content-type": "application/json",
|
|
83
86
|
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
84
|
-
...
|
|
87
|
+
...identity,
|
|
85
88
|
},
|
|
89
|
+
signal: AbortSignal.timeout(20_000),
|
|
86
90
|
body: JSON.stringify(body ?? {}),
|
|
87
91
|
});
|
|
88
92
|
}
|
|
@@ -259,11 +263,7 @@ export async function beginDeviceLogin(env, agentName, options = {}) {
|
|
|
259
263
|
let pending = await store.readPending();
|
|
260
264
|
const resumed = pending !== null;
|
|
261
265
|
if (!pending) {
|
|
262
|
-
const pair = await
|
|
263
|
-
modulusLength: 2048,
|
|
264
|
-
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
265
|
-
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
266
|
-
});
|
|
266
|
+
const pair = await sealing.pair();
|
|
267
267
|
// The computer's own name goes to the approval page beside the code, so an
|
|
268
268
|
// owner can tell this request from a link somebody else started.
|
|
269
269
|
const started = await request("start", {
|
package/dist/products/ci.d.ts
CHANGED
|
@@ -27,6 +27,9 @@ export type CiRepository = {
|
|
|
27
27
|
/** The GitHub Apps (`<app-slug>[bot]`, lowercase, sorted) whose pushes run
|
|
28
28
|
* as the code of someone who can write. */
|
|
29
29
|
trustedApps: string[];
|
|
30
|
+
/** Whether a red production branch's culprit is undone by a pull request
|
|
31
|
+
* CI/CD opens: off until `setUndo` turns it on. */
|
|
32
|
+
undoCulprits: boolean;
|
|
30
33
|
state: "connected" | "disconnected";
|
|
31
34
|
};
|
|
32
35
|
export type ConnectCiRepository = {
|
|
@@ -302,6 +305,12 @@ export declare function ci(t: Transport): {
|
|
|
302
305
|
trust: (id: string, apps: string[], options?: RequestOptions) => Promise<CiRepository>;
|
|
303
306
|
/** Takes Apps off those it trusts, keeping the rest. */
|
|
304
307
|
untrust: (id: string, apps: string[], options?: RequestOptions) => Promise<CiRepository>;
|
|
308
|
+
/** Turn on (or off) the undo of a red production branch's culprit:
|
|
309
|
+
* when main's check fails, CI/CD finds the first commit that fails it
|
|
310
|
+
* and opens a pull request reverting it, which lands once its own
|
|
311
|
+
* check passes. Off until set; only an owner's or admin's full key
|
|
312
|
+
* sets it. */
|
|
313
|
+
setUndo: (id: string, on: boolean, options?: RequestOptions) => Promise<CiRepository>;
|
|
305
314
|
/** Disconnect: its checks still going are cancelled; what ended stays. */
|
|
306
315
|
disconnect: (id: string, options?: RequestOptions) => Promise<CiRepository>;
|
|
307
316
|
};
|
|
@@ -310,6 +319,9 @@ export declare function ci(t: Transport): {
|
|
|
310
319
|
list: (query?: ListCiChecks, options?: RequestOptions) => Promise<Page<CiCheck>>;
|
|
311
320
|
/** A check with its jobs in plan order. */
|
|
312
321
|
get: (id: string, options?: RequestOptions) => Promise<CiCheckDetail>;
|
|
322
|
+
/** Cancel a queued or running check: its jobs end and its machines
|
|
323
|
+
* are let go. A check that has ended is answered as it is. */
|
|
324
|
+
cancel: (id: string, options?: RequestOptions) => Promise<CiCheckDetail>;
|
|
313
325
|
};
|
|
314
326
|
jobs: {
|
|
315
327
|
/** A job with the tail of its log and the sandboxes it ran in. */
|
package/dist/products/ci.js
CHANGED
|
@@ -61,6 +61,17 @@ export function ci(t) {
|
|
|
61
61
|
const drop = new Set(apps.map((a) => a.toLowerCase()));
|
|
62
62
|
return setTrustedApps(repo.id, repo.trustedApps.filter((a) => !drop.has(a)), options);
|
|
63
63
|
},
|
|
64
|
+
/** Turn on (or off) the undo of a red production branch's culprit:
|
|
65
|
+
* when main's check fails, CI/CD finds the first commit that fails it
|
|
66
|
+
* and opens a pull request reverting it, which lands once its own
|
|
67
|
+
* check passes. Off until set; only an owner's or admin's full key
|
|
68
|
+
* sets it. */
|
|
69
|
+
setUndo: (id, on, options) => t.json({
|
|
70
|
+
method: "POST",
|
|
71
|
+
path: `${at("/v1/ci/repositories", id)}:update`,
|
|
72
|
+
body: { undoCulprits: on },
|
|
73
|
+
...options,
|
|
74
|
+
}),
|
|
64
75
|
/** Disconnect: its checks still going are cancelled; what ended stays. */
|
|
65
76
|
disconnect: (id, options) => t.json({
|
|
66
77
|
method: "DELETE",
|
|
@@ -73,6 +84,13 @@ export function ci(t) {
|
|
|
73
84
|
list: (query = {}, options) => paged("/v1/ci/checks", query, options),
|
|
74
85
|
/** A check with its jobs in plan order. */
|
|
75
86
|
get: (id, options) => t.json({ method: "GET", path: at("/v1/ci/checks", id), ...options }),
|
|
87
|
+
/** Cancel a queued or running check: its jobs end and its machines
|
|
88
|
+
* are let go. A check that has ended is answered as it is. */
|
|
89
|
+
cancel: (id, options) => t.json({
|
|
90
|
+
method: "POST",
|
|
91
|
+
path: `${at("/v1/ci/checks", id)}:cancel`,
|
|
92
|
+
...options,
|
|
93
|
+
}),
|
|
76
94
|
},
|
|
77
95
|
jobs: {
|
|
78
96
|
/** A job with the tail of its log and the sandboxes it ran in. */
|
package/dist/sealing.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { generateKeyPair } from "node:crypto";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
/* The key pair a browser approval seals its answer to (RSA-OAEP, SHA-256):
|
|
4
|
+
a login's credential, or a key asked for with `keys create`. Made fresh
|
|
5
|
+
for each request, so only this process can open what the page sends. An
|
|
6
|
+
object, so a test file can make one pair and reuse it: each takes 80 ms of
|
|
7
|
+
CPU to make, most of the login and key tests' time (24 October 2026). */
|
|
8
|
+
export const sealing = {
|
|
9
|
+
pair: () => promisify(generateKeyPair)("rsa", {
|
|
10
|
+
modulusLength: 2048,
|
|
11
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
12
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
13
|
+
}),
|
|
14
|
+
};
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RuntimeError } from "./errors.js";
|
|
2
|
-
export declare const VERSION = "0.33.
|
|
2
|
+
export declare const VERSION = "0.33.2";
|
|
3
3
|
export declare const DEFAULT_BASE_URL = "https://api.withruntime.com";
|
|
4
4
|
/** Runtime's API as code inside a Runtime sandbox reaches it: the sandbox's
|
|
5
5
|
* own host sends each request on to DEFAULT_BASE_URL over HTTPS. The API runs
|
package/dist/transport.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ConnectionError, DELIBERATE, errorFor, NOTHING_RAN, RuntimeError, WAITS_FOR_ROOM, } from "./errors.js";
|
|
2
2
|
import { describeRoute, envFetch, openWebSocket, untrustedCertificate } from "./proxy.js";
|
|
3
3
|
import { warningText } from "./product-renames.js";
|
|
4
|
-
export const VERSION = "0.33.
|
|
4
|
+
export const VERSION = "0.33.2";
|
|
5
5
|
export const DEFAULT_BASE_URL = "https://api.withruntime.com";
|
|
6
6
|
/** Runtime's API as code inside a Runtime sandbox reaches it: the sandbox's
|
|
7
7
|
* own host sends each request on to DEFAULT_BASE_URL over HTTPS. The API runs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "withruntime",
|
|
3
|
-
"version": "0.33.
|
|
3
|
+
"version": "0.33.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"mcpName": "com.withruntime/runtime",
|
|
6
6
|
"type": "module",
|
|
@@ -197,8 +197,8 @@
|
|
|
197
197
|
},
|
|
198
198
|
"scripts": {
|
|
199
199
|
"build": "node ../typescript-config/tsc5.mjs -p tsconfig.build.json",
|
|
200
|
-
"check-types": "tsgo --noEmit",
|
|
201
|
-
"lint": "oxlint -c ../eslint-config/oxlint.json --ignore-pattern 'dist/**' --ignore-pattern 'tests/fixtures/modal/**'",
|
|
200
|
+
"check-types": "tsgo --noEmit && tsgo -p tests/compat/consumers",
|
|
201
|
+
"lint": "oxlint -c ../eslint-config/oxlint.json --ignore-pattern 'dist/**' --ignore-pattern 'tests/fixtures/modal/**' --ignore-pattern 'tests/compat/consumers/**'",
|
|
202
202
|
"test": "bun test"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|