@plexus-ms/cli-ci 0.109.0 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/plx-ci.js +1045 -773
  2. package/package.json +2 -2
package/dist/plx-ci.js CHANGED
@@ -7395,7 +7395,7 @@ function realIo(prompt) {
7395
7395
  stdout: process.stdout,
7396
7396
  stderr: process.stderr,
7397
7397
  prompt: prompt && isInteractive() ? prompt : void 0,
7398
- exec: (command, args, options = {}) => new Promise((resolve4, reject) => {
7398
+ exec: (command, args, options = {}) => new Promise((resolve5, reject) => {
7399
7399
  const child = spawn(command, args, {
7400
7400
  cwd: options.cwd,
7401
7401
  env: options.env,
@@ -7418,7 +7418,7 @@ function realIo(prompt) {
7418
7418
  child.on("error", reject);
7419
7419
  child.on("close", (code) => {
7420
7420
  if (code === 0)
7421
- resolve4(stdout);
7421
+ resolve5(stdout);
7422
7422
  else
7423
7423
  reject(new ExecError(render(command, args), code, stderr));
7424
7424
  });
@@ -7437,7 +7437,7 @@ function quote(arg) {
7437
7437
  return /^[\w./:@%+=,-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`;
7438
7438
  }
7439
7439
 
7440
- // ../lib/dist/plan.js
7440
+ // ../lib/dist/effects.js
7441
7441
  var run = (command, args = [], options = {}) => ({
7442
7442
  kind: "run",
7443
7443
  command,
@@ -7599,6 +7599,7 @@ var import_yaml3 = __toESM(require_dist(), 1);
7599
7599
 
7600
7600
  // ../lib/dist/deployTargets.js
7601
7601
  var import_yaml4 = __toESM(require_dist(), 1);
7602
+ import { join } from "node:path";
7602
7603
 
7603
7604
  // ../lib/dist/train.js
7604
7605
  var PRIMITIVES = "primitives";
@@ -7613,6 +7614,72 @@ var PHASES = [[PRIMITIVES], TRAIN_REPOS.filter((r) => r !== PRIMITIVES)];
7613
7614
  var UsageError = class extends Error {
7614
7615
  };
7615
7616
 
7617
+ // ../lib/dist/deployTargets.js
7618
+ var VARS_DIRS = ["group_vars", "host_vars"];
7619
+ var DEFAULT_ENVIRONMENT = "production";
7620
+ function parseInventoryFile(path2, text) {
7621
+ const data = (0, import_yaml4.parse)(text);
7622
+ const vars = data !== null && typeof data === "object" ? data : {};
7623
+ const target = typeof vars.plexus_deploy_target === "string" ? vars.plexus_deploy_target : void 0;
7624
+ const records = Array.isArray(vars.plexus_apps) ? vars.plexus_apps : [];
7625
+ const apps = records.flatMap((r) => {
7626
+ if (r === null || typeof r !== "object")
7627
+ return [];
7628
+ const { name, environment } = r;
7629
+ if (typeof name !== "string")
7630
+ return [];
7631
+ return [{ app: name, environment: typeof environment === "string" ? environment : DEFAULT_ENVIRONMENT }];
7632
+ });
7633
+ return { path: path2, target, apps };
7634
+ }
7635
+ function selectDeployTargets(files) {
7636
+ const targets = {};
7637
+ const declaredIn = {};
7638
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
7639
+ if (file.target === void 0)
7640
+ continue;
7641
+ for (const { app, environment } of file.apps) {
7642
+ const key = `${app}@${environment}`;
7643
+ if (key in declaredIn)
7644
+ throw new Error(`app "${app}" in ${environment} is declared with a deploy target in both ${declaredIn[key]} and ${file.path}`);
7645
+ const envs = targets[app] ?? {};
7646
+ envs[environment] = file.target;
7647
+ targets[app] = envs;
7648
+ declaredIn[key] = file.path;
7649
+ }
7650
+ }
7651
+ return Object.fromEntries(Object.entries(targets).sort(([a], [b]) => a.localeCompare(b)).map(([app, envs]) => [app, Object.fromEntries(Object.entries(envs).sort(([a], [b]) => a.localeCompare(b)))]));
7652
+ }
7653
+ function targetsIn(targets, environment) {
7654
+ const flat = {};
7655
+ for (const [app, envs] of Object.entries(targets)) {
7656
+ const target = envs[environment];
7657
+ if (target !== void 0)
7658
+ flat[app] = target;
7659
+ }
7660
+ return flat;
7661
+ }
7662
+ async function readDeployTargets(ctx, inventory) {
7663
+ const files = [];
7664
+ let found = false;
7665
+ for (const sub of VARS_DIRS) {
7666
+ const dir = join(inventory, sub);
7667
+ const entries = await ctx.readDir(dir).catch(() => void 0);
7668
+ if (entries === void 0)
7669
+ continue;
7670
+ found = true;
7671
+ for (const e of entries) {
7672
+ if (e.directory || !/\.ya?ml$/.test(e.name))
7673
+ continue;
7674
+ const path2 = join(dir, e.name);
7675
+ files.push(parseInventoryFile(path2, await ctx.readFile(path2)));
7676
+ }
7677
+ }
7678
+ if (!found && await ctx.readDir(inventory).catch(() => void 0) === void 0)
7679
+ throw new UsageError(`no inventory directory at ${inventory}`);
7680
+ return selectDeployTargets(files);
7681
+ }
7682
+
7616
7683
  // ../lib/dist/ship.js
7617
7684
  var DEFAULT_WAIT = { interval: 15e3, timeout: 20 * 6e4 };
7618
7685
 
@@ -10997,7 +11064,7 @@ import { resolve } from "node:path";
10997
11064
  // src/core/build.ts
10998
11065
  import { randomBytes } from "node:crypto";
10999
11066
  import { tmpdir } from "node:os";
11000
- import { join } from "node:path";
11067
+ import { join as join2 } from "node:path";
11001
11068
 
11002
11069
  // src/core/exoscale.ts
11003
11070
  var Exo = class {
@@ -11044,7 +11111,7 @@ var Exo = class {
11044
11111
  async securityGroupId(name) {
11045
11112
  const data = await this.read("list-security-groups");
11046
11113
  const g = (data["security-groups"] ?? []).find((g2) => g2.name === name);
11047
- if (!g) throw new Error(`no security group "${name}"; it is created once, at setup (ci.md)`);
11114
+ if (!g) throw new Error(`no security group "${name}"; it is created once, at setup`);
11048
11115
  return g.id;
11049
11116
  }
11050
11117
  createInstance(request) {
@@ -11061,8 +11128,11 @@ var Exo = class {
11061
11128
  };
11062
11129
  return this.effect("create-instance", [], JSON.stringify(body2));
11063
11130
  }
11064
- /** Poll an operation to its end; the create completes within seconds. */
11065
- async waitOperation(id, attempts = 30) {
11131
+ /**
11132
+ * Poll an operation to its end, for three minutes: a create completes
11133
+ * within seconds as a rule and has taken more than one minute under load.
11134
+ */
11135
+ async waitOperation(id, attempts = 90) {
11066
11136
  let state = "pending";
11067
11137
  for (let i = 0; i < attempts && state === "pending"; i++) {
11068
11138
  if (i > 0) await sleep(2e3);
@@ -11076,33 +11146,9 @@ var Exo = class {
11076
11146
  stopInstance(id) {
11077
11147
  return this.effect("stop-instance", [id]);
11078
11148
  }
11079
- startInstance(id) {
11080
- return this.effect("start-instance", [id]);
11081
- }
11082
- rebootInstance(id) {
11083
- return this.effect("reboot-instance", [id]);
11084
- }
11085
- };
11086
- var Storage = class {
11087
- ctx;
11088
- env;
11089
- constructor(ctx, accessKey, secretKey) {
11090
- this.ctx = ctx;
11091
- this.env = { EXOSCALE_API_KEY: accessKey, EXOSCALE_API_SECRET: secretKey };
11092
- }
11093
- async bucketExists(bucket) {
11094
- try {
11095
- await this.ctx.read("exo", ["storage", "ls", `sos://${bucket}`], { env: { ...process.env, ...this.env } });
11096
- return true;
11097
- } catch {
11098
- return false;
11099
- }
11100
- }
11101
- makeBucket(bucket, zone) {
11102
- return this.ctx.apply([run("exo", ["storage", "mb", `sos://${bucket}`, "-z", zone], { env: this.env })]);
11103
- }
11104
- removeBucket(bucket) {
11105
- return this.ctx.apply([run("exo", ["storage", "rb", "-r", "-f", `sos://${bucket}`], { env: this.env })]);
11149
+ /** Rename and relabel: the whole label map is sent, since the API replaces it. */
11150
+ updateInstance(id, name, labels) {
11151
+ return this.effect("update-instance", [id], JSON.stringify({ name, labels }));
11106
11152
  }
11107
11153
  };
11108
11154
  function instanceOf(raw) {
@@ -11117,6 +11163,118 @@ function instanceOf(raw) {
11117
11163
  }
11118
11164
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11119
11165
 
11166
+ // src/core/s3.ts
11167
+ import { createHash } from "node:crypto";
11168
+ var sha256 = (text) => createHash("sha256").update(text).digest("hex");
11169
+ var md5 = (text) => createHash("md5").update(text).digest("base64");
11170
+ var unescapeXml = (text) => text.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&apos;", "'").replaceAll("&amp;", "&");
11171
+ var escapeXml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
11172
+ function elements(xml, tag) {
11173
+ return [...xml.matchAll(new RegExp(`<${tag}>([^<]*)</${tag}>`, "g"))].map((m) => unescapeXml(m[1] ?? ""));
11174
+ }
11175
+ function uploads(xml) {
11176
+ return [...xml.matchAll(/<Upload>(.*?)<\/Upload>/gs)].flatMap((m) => {
11177
+ const key = elements(m[1] ?? "", "Key")[0];
11178
+ const id = elements(m[1] ?? "", "UploadId")[0];
11179
+ return key !== void 0 && id !== void 0 ? [{ key, id }] : [];
11180
+ });
11181
+ }
11182
+ function deleteBody(keys) {
11183
+ return `<Delete><Quiet>true</Quiet>${keys.map((k) => `<Object><Key>${escapeXml(k)}</Key></Object>`).join("")}</Delete>`;
11184
+ }
11185
+ var S3 = class {
11186
+ ctx;
11187
+ storage;
11188
+ constructor(ctx, storage) {
11189
+ this.ctx = ctx;
11190
+ this.storage = storage;
11191
+ }
11192
+ /** The curl config of one call: the URL, the signature's inputs, the body, and the status after the answer. */
11193
+ config(call) {
11194
+ const { endpoint, region, accessKey, secretKey } = this.storage;
11195
+ const url = `${endpoint.replace(/\/$/, "")}/${call.path}${call.query ? `?${call.query}` : ""}`;
11196
+ const body2 = call.body ?? "";
11197
+ const lines = [
11198
+ `url = ${JSON.stringify(url)}`,
11199
+ call.method === "HEAD" ? "head" : `request = "${call.method}"`,
11200
+ "silent",
11201
+ "show-error",
11202
+ "max-time = 60",
11203
+ `user = ${JSON.stringify(`${accessKey}:${secretKey}`)}`,
11204
+ `aws-sigv4 = "aws:amz:${region}:s3"`,
11205
+ // Stated, not left to curl: its versions differ in whether they send it, and S3 wants it signed.
11206
+ `header = "x-amz-content-sha256: ${sha256(body2)}"`,
11207
+ ...(call.headers ?? []).map((h) => `header = ${JSON.stringify(h)}`),
11208
+ ...call.body === void 0 ? [] : [`data-binary = ${JSON.stringify(body2)}`],
11209
+ 'write-out = "\\n%{http_code}"'
11210
+ ];
11211
+ return `${lines.join("\n")}
11212
+ `;
11213
+ }
11214
+ answer(out) {
11215
+ const at = out.lastIndexOf("\n");
11216
+ return { status: Number(out.slice(at + 1)), body: out.slice(0, at) };
11217
+ }
11218
+ async read(call) {
11219
+ return this.answer(await this.ctx.read("curl", ["-K", "-"], { input: this.config(call) }));
11220
+ }
11221
+ /** An effect: its answer, or undefined under --dry-run. A status outside 2xx is an error. */
11222
+ async effect(call, what) {
11223
+ const out = await this.ctx.effect(run("curl", ["-K", "-"], { input: this.config(call) }));
11224
+ if (out === void 0) return void 0;
11225
+ const answer = this.answer(out);
11226
+ if (answer.status < 200 || answer.status >= 300)
11227
+ throw new Error(`${what} answered ${answer.status}: ${answer.body.slice(0, 300)}`);
11228
+ return answer;
11229
+ }
11230
+ async bucketExists(bucket) {
11231
+ const { status } = await this.read({ method: "HEAD", path: bucket });
11232
+ if (status === 200) return true;
11233
+ if (status === 404) return false;
11234
+ throw new Error(`looking up the bucket ${bucket} answered ${status}`);
11235
+ }
11236
+ async makeBucket(bucket) {
11237
+ await this.effect({ method: "PUT", path: bucket }, `creating the bucket ${bucket}`);
11238
+ }
11239
+ /**
11240
+ * Delete a bucket whole: its objects a thousand at a time, the uploads
11241
+ * something left unfinished, then the bucket, which S3 refuses while
11242
+ * either remains. A dry run lists once and says what would go.
11243
+ */
11244
+ async removeBucket(bucket) {
11245
+ let removed = 0;
11246
+ for (; ; ) {
11247
+ const page = await this.read({ method: "GET", path: bucket, query: "list-type=2&max-keys=1000" });
11248
+ if (page.status !== 200) throw new Error(`listing the bucket ${bucket} answered ${page.status}`);
11249
+ const keys = elements(page.body, "Key");
11250
+ if (keys.length === 0) break;
11251
+ const body2 = deleteBody(keys);
11252
+ const answer = await this.effect(
11253
+ { method: "POST", path: bucket, query: "delete=", body: body2, headers: [`Content-MD5: ${md5(body2)}`] },
11254
+ `deleting ${keys.length} objects of ${bucket}`
11255
+ );
11256
+ if (answer === void 0) {
11257
+ this.ctx.say(` (the objects of ${bucket}, ${keys.length} in the first listing, then the bucket)`);
11258
+ return;
11259
+ }
11260
+ const failed = elements(answer.body, "Message");
11261
+ if (answer.body.includes("<Error>"))
11262
+ throw new Error(`deleting objects of ${bucket} failed: ${failed[0] ?? answer.body.slice(0, 300)}`);
11263
+ removed += keys.length;
11264
+ }
11265
+ const pending = await this.read({ method: "GET", path: bucket, query: "uploads=" });
11266
+ for (const upload of pending.status === 200 ? uploads(pending.body) : []) {
11267
+ const key = upload.key.split("/").map(encodeURIComponent).join("/");
11268
+ await this.effect(
11269
+ { method: "DELETE", path: `${bucket}/${key}`, query: `uploadId=${encodeURIComponent(upload.id)}` },
11270
+ `aborting an unfinished upload in ${bucket}`
11271
+ );
11272
+ }
11273
+ await this.effect({ method: "DELETE", path: bucket }, `deleting the bucket ${bucket}`);
11274
+ this.ctx.say(`removed the bucket ${bucket} and its ${removed} object(s)`);
11275
+ }
11276
+ };
11277
+
11120
11278
  // src/core/time.ts
11121
11279
  function iso(at) {
11122
11280
  return at.toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -11134,6 +11292,10 @@ function registryBuckets(owner, now) {
11134
11292
  }
11135
11293
 
11136
11294
  // src/core/registry.ts
11295
+ var imageName = (prefix, app) => `${prefix.toLowerCase().replace(/\/$/, "")}/${app}`;
11296
+ function registryOf(kind, prefix) {
11297
+ return kind === "own" ? { kind } : { kind, prefix };
11298
+ }
11137
11299
  var CURRENT_PORT = 5e3;
11138
11300
  var PREVIOUS_PORT = 5001;
11139
11301
  var REGISTRY_IMAGE = "registry:2";
@@ -11173,7 +11335,10 @@ function sidecarArgs(name, port, bucket, storage, readOnly) {
11173
11335
  "-e",
11174
11336
  "REGISTRY_STORAGE_S3_FORCEPATHSTYLE=true",
11175
11337
  "-e",
11176
- readOnly ? "REGISTRY_STORAGE_MAINTENANCE_READONLY_ENABLED=true" : "REGISTRY_STORAGE_DELETE_ENABLED=true",
11338
+ // registry:2 panics on the read-only flag as one nested variable
11339
+ // (…_READONLY_ENABLED=true): the map it builds from it has the wrong
11340
+ // type. The whole key as one YAML value is read as from a config file.
11341
+ readOnly ? 'REGISTRY_STORAGE_MAINTENANCE_READONLY={"enabled":true}' : "REGISTRY_STORAGE_DELETE_ENABLED=true",
11177
11342
  REGISTRY_IMAGE
11178
11343
  ];
11179
11344
  }
@@ -11185,12 +11350,19 @@ async function answers(ctx, port) {
11185
11350
  return false;
11186
11351
  }
11187
11352
  }
11188
- async function startSidecars(ctx, storage, owner, zone, now = /* @__PURE__ */ new Date()) {
11353
+ async function startSidecars(ctx, storage, owner, now = /* @__PURE__ */ new Date()) {
11189
11354
  const buckets = registryBuckets(owner, now);
11190
- const sos = new Storage(ctx, storage.accessKey, storage.secretKey);
11191
- if (!await sos.bucketExists(buckets.current)) {
11192
- ctx.say(`\u2192 creating the bucket ${buckets.current} in ${zone}`);
11193
- await sos.makeBucket(buckets.current, zone);
11355
+ if (!ctx.dryRun && await answers(ctx, CURRENT_PORT)) {
11356
+ const previousAnswers2 = await answers(ctx, PREVIOUS_PORT);
11357
+ ctx.say(`registry: :${CURRENT_PORT} answers already, the host's pair; nothing started`);
11358
+ return { current: buckets.current, previous: buckets.previous, previousAnswers: previousAnswers2 };
11359
+ }
11360
+ if (!storage)
11361
+ throw new Error("REGISTRY_ENDPOINT, REGISTRY_REGION, REGISTRY_ACCESS_KEY and REGISTRY_SECRET_KEY are needed");
11362
+ const s3 = new S3(ctx, storage);
11363
+ if (!await s3.bucketExists(buckets.current)) {
11364
+ ctx.say(`\u2192 creating the bucket ${buckets.current}`);
11365
+ await s3.makeBucket(buckets.current);
11194
11366
  }
11195
11367
  await ctx.apply([
11196
11368
  run("docker", sidecarArgs("registry", CURRENT_PORT, buckets.current, storage, false)),
@@ -11257,14 +11429,247 @@ async function layers(ctx, app, tag, port = CURRENT_PORT) {
11257
11429
  return void 0;
11258
11430
  }
11259
11431
  }
11432
+ function candidateRef(registry, app, tag) {
11433
+ return registry.kind === "own" ? `localhost:${CURRENT_PORT}/${app}:${tag}` : `${imageName(registry.prefix, app)}:${tag}`;
11434
+ }
11435
+ async function craneDigest(ctx, ref) {
11436
+ try {
11437
+ return (await ctx.read("crane", ["digest", ref])).trim() || void 0;
11438
+ } catch {
11439
+ return void 0;
11440
+ }
11441
+ }
11442
+ async function findTag(ctx, registry, app, tag) {
11443
+ if (registry.kind === "own") {
11444
+ const port = await tagExists(ctx, app, tag);
11445
+ return port === void 0 ? void 0 : `localhost:${port}/${app}:${tag}`;
11446
+ }
11447
+ const ref = candidateRef(registry, app, tag);
11448
+ return await craneDigest(ctx, ref) === void 0 ? void 0 : ref;
11449
+ }
11450
+ function digestOf(ctx, registry, app, tag) {
11451
+ return registry.kind === "own" ? digest(ctx, app, tag) : craneDigest(ctx, candidateRef(registry, app, tag));
11452
+ }
11453
+ async function layersOf(ctx, registry, app, tag) {
11454
+ if (registry.kind === "own") return layers(ctx, app, tag);
11455
+ try {
11456
+ const data = JSON.parse(await ctx.read("crane", ["manifest", candidateRef(registry, app, tag)]));
11457
+ return (data.layers ?? []).map((l) => ({ digest: l.digest, size: l.size }));
11458
+ } catch {
11459
+ return void 0;
11460
+ }
11461
+ }
11462
+ async function loggedIn(ctx, host, env = process.env) {
11463
+ const file = `${env.DOCKER_CONFIG || `${env.HOME}/.docker`}/config.json`;
11464
+ try {
11465
+ const config = JSON.parse(await ctx.readFile(file));
11466
+ return Boolean(config.auths?.[host] || config.credHelpers?.[host] || config.credsStore);
11467
+ } catch {
11468
+ return false;
11469
+ }
11470
+ }
11471
+ async function rotateBuckets(ctx, storage, owner, now = /* @__PURE__ */ new Date()) {
11472
+ const buckets = registryBuckets(owner, now);
11473
+ const s3 = new S3(ctx, storage);
11474
+ const result = {};
11475
+ if (!await s3.bucketExists(buckets.current)) {
11476
+ ctx.say(`rotate: creating the bucket ${buckets.current}`);
11477
+ await s3.makeBucket(buckets.current);
11478
+ result.created = buckets.current;
11479
+ }
11480
+ if (await s3.bucketExists(buckets.expired)) {
11481
+ ctx.say(`rotate: the registry bucket ${buckets.expired} is two months old`);
11482
+ await s3.removeBucket(buckets.expired);
11483
+ result.removed = buckets.expired;
11484
+ }
11485
+ return result;
11486
+ }
11487
+
11488
+ // src/core/strategy.ts
11489
+ var STRATEGIES = ["github", "on-prem", "hybrid"];
11490
+ var REGISTRIES = ["ghcr", "own"];
11491
+ var OVERLAYS = ["none", "netbird"];
11492
+ function oneOf(name, value, allowed) {
11493
+ if (!allowed.includes(value))
11494
+ throw new UsageError(`${name} is "${value}"; it is one of ${allowed.join(", ")}`);
11495
+ return value;
11496
+ }
11497
+ function settingsOf(env = process.env, flag) {
11498
+ const strategy = oneOf("the strategy", flag || env.CI_STRATEGY || "github", STRATEGIES);
11499
+ const registry = oneOf("CI_REGISTRY", env.CI_REGISTRY || (strategy === "github" ? "ghcr" : "own"), REGISTRIES);
11500
+ const overlay = oneOf("CI_OVERLAY", env.CI_OVERLAY || "none", OVERLAYS);
11501
+ return { strategy, registry, overlay };
11502
+ }
11503
+ var sizeLabel = (size) => `ci-${size}`;
11504
+ function runnerOf(strategy, size, instance) {
11505
+ if (strategy === "github") return "ubuntu-latest";
11506
+ if (strategy === "hybrid" && size === "large") {
11507
+ if (!instance) throw new Error("a large job under hybrid needs its instance\u2019s label");
11508
+ return instance;
11509
+ }
11510
+ return sizeLabel(size);
11511
+ }
11512
+
11513
+ // src/core/plan.ts
11514
+ var NONEXISTENT = "<NONEXISTENT>";
11515
+ function hashesOf(dryRun, packages) {
11516
+ const data = JSON.parse(dryRun);
11517
+ const tasks = (data.tasks ?? []).filter((t) => t.command !== NONEXISTENT);
11518
+ return Object.entries(packages).flatMap(([app, name]) => {
11519
+ const build2 = tasks.find((t) => t.package === name && t.taskId.endsWith("#build"));
11520
+ if (!build2) return [];
11521
+ const test2 = tasks.find((t) => t.package === name && t.taskId.endsWith("#test"));
11522
+ return [{ app, hash: build2.hash, ...test2 ? { test: test2.hash } : {} }];
11523
+ });
11524
+ }
11525
+ async function packageName(ctx, dir, app) {
11526
+ const pkg = JSON.parse(await ctx.readFile(`${dir}/apps/${app}/package.json`));
11527
+ if (!pkg.name) throw new UsageError(`apps/${app}/package.json has no name`);
11528
+ return pkg.name;
11529
+ }
11530
+ async function turboOf(ctx, dir) {
11531
+ const pkg = JSON.parse(await ctx.readFile(`${dir}/package.json`));
11532
+ return `turbo@${pkg.devDependencies?.turbo ?? "latest"}`;
11533
+ }
11534
+ async function readHashes(ctx, dir, apps) {
11535
+ const packages = {};
11536
+ for (const app of apps) packages[app] = await packageName(ctx, dir, app);
11537
+ const out = await ctx.read("npx", ["--yes", await turboOf(ctx, dir), "run", "build", "test", "--dry-run=json"], {
11538
+ cwd: dir
11539
+ });
11540
+ return hashesOf(out, packages);
11541
+ }
11542
+ function legsOf(apps, hashes, mode, strategy, run2) {
11543
+ return apps.flatMap((app) => {
11544
+ const hash = hashes.find((h) => h.app === app)?.hash;
11545
+ if (!hash) throw new UsageError(`no build hash for ${app}; is it an app package of the workspace?`);
11546
+ const copies = mode === "repro" ? 2 : 1;
11547
+ return Array.from({ length: copies }, (_, i) => {
11548
+ const n = i + 1;
11549
+ const base = `exo-build-${run2.runId}-${app}-${run2.attempt}`;
11550
+ return { app, hash, runner: runnerOf(strategy, "large", mode === "repro" ? `${base}-${n}` : base), n };
11551
+ });
11552
+ });
11553
+ }
11554
+ function testLegsOf(apps, hashes, strategy, run2) {
11555
+ return apps.flatMap((app) => {
11556
+ const h = hashes.find((x) => x.app === app);
11557
+ if (!h?.test) return [];
11558
+ const runner = runnerOf(strategy, "large", `exo-test-${run2.runId}-${app}-${run2.attempt}`);
11559
+ return [{ app, hash: h.test, build: h.hash, runner }];
11560
+ });
11561
+ }
11562
+ function environmentOf(event, ref, given = "") {
11563
+ if (given) return given;
11564
+ if (event !== "push" && event !== "workflow_dispatch") return "";
11565
+ return ref === "main" ? "production" : ref === "develop" ? "staging" : "";
11566
+ }
11567
+ function deployLegsOf(apps, targets, prefix, sha) {
11568
+ const legs = [];
11569
+ const without = [];
11570
+ for (const app of apps) {
11571
+ const host = targets[app];
11572
+ if (host === void 0) without.push(app);
11573
+ else legs.push({ app, host, image: `${imageName(prefix, app)}:${sha}` });
11574
+ }
11575
+ return { legs, without };
11576
+ }
11577
+ async function plan(ctx, options) {
11578
+ const { apps, hashes, mode, strategy, registry, run: run2 } = options;
11579
+ const result = {
11580
+ legs: [],
11581
+ tests: [],
11582
+ apps: apps.map((app) => {
11583
+ const h = hashes.find((x) => x.app === app);
11584
+ if (!h) throw new UsageError(`no build hash for ${app}; is it an app package of the workspace?`);
11585
+ return h;
11586
+ }),
11587
+ environment: options.deploy?.environment ?? "",
11588
+ deploys: [],
11589
+ skipped: [],
11590
+ skippedTests: []
11591
+ };
11592
+ for (const leg of legsOf(apps, hashes, mode, strategy, run2)) {
11593
+ if (mode === "build") {
11594
+ const ref = await findTag(ctx, registry, leg.app, `build-${leg.hash}`);
11595
+ if (ref !== void 0) {
11596
+ ctx.say(`${leg.app}: ${ref} exists, nothing to build`);
11597
+ result.skipped.push({ app: leg.app, hash: leg.hash, ref });
11598
+ continue;
11599
+ }
11600
+ }
11601
+ result.legs.push(leg);
11602
+ }
11603
+ if (options.tests && mode !== "repro") {
11604
+ for (const leg of testLegsOf(apps, hashes, strategy, run2)) {
11605
+ if (mode === "build") {
11606
+ const ref = await findTag(ctx, registry, leg.app, `test-${leg.hash}`);
11607
+ if (ref !== void 0) {
11608
+ ctx.say(`${leg.app}: ${ref} exists, nothing to test`);
11609
+ result.skippedTests.push({ app: leg.app, hash: leg.hash, ref });
11610
+ continue;
11611
+ }
11612
+ }
11613
+ result.tests.push(leg);
11614
+ }
11615
+ }
11616
+ if (options.deploy?.environment) {
11617
+ const { environment, targets, prefix, sha } = options.deploy;
11618
+ const { legs, without } = deployLegsOf(apps, targets, prefix, sha);
11619
+ result.deploys = legs;
11620
+ for (const app of without) ctx.say(`${app} has no deployment in ${environment}; nothing to deploy`);
11621
+ }
11622
+ return result;
11623
+ }
11624
+ async function report(ctx, registry, legs, mode, runId) {
11625
+ const apps = [...new Set(legs.map((l) => l.app))].sort();
11626
+ const out = [];
11627
+ for (const app of apps) {
11628
+ const hash = legs.find((l) => l.app === app)?.hash ?? "";
11629
+ const c = { app, hash, digest: await digestOf(ctx, registry, app, `build-${hash}`) };
11630
+ ctx.say(`${app} build-${hash}: ${c.digest ?? "missing"}`);
11631
+ if (mode === "repro") {
11632
+ const [one, two] = await Promise.all([
11633
+ digestOf(ctx, registry, app, `repro-${runId}-1`),
11634
+ digestOf(ctx, registry, app, `repro-${runId}-2`)
11635
+ ]);
11636
+ const identical = one !== void 0 && one === two;
11637
+ const [l1, l2] = identical ? [[], []] : await Promise.all([
11638
+ layersOf(ctx, registry, app, `repro-${runId}-1`),
11639
+ layersOf(ctx, registry, app, `repro-${runId}-2`)
11640
+ ]);
11641
+ const differing = [];
11642
+ const a = l1 ?? [];
11643
+ const b = l2 ?? [];
11644
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
11645
+ if (a[i]?.digest !== b[i]?.digest)
11646
+ differing.push(
11647
+ `layer ${i + 1}: ${a[i]?.digest ?? "-"} (${a[i]?.size ?? "-"}) vs ${b[i]?.digest ?? "-"} (${b[i]?.size ?? "-"})`
11648
+ );
11649
+ }
11650
+ c.repro = { one, two, identical, layers: a.length, same: a.length - differing.length, differing };
11651
+ if (identical) ctx.say(` reproducible: both builds are ${one}`);
11652
+ else {
11653
+ ctx.say(
11654
+ ` not identical: ${one ?? "-"} vs ${two ?? "-"}; layers: ${a.length} in each, ${a.length - differing.length} identical`
11655
+ );
11656
+ for (const d of differing) ctx.say(` ${d}`);
11657
+ }
11658
+ }
11659
+ out.push(c);
11660
+ }
11661
+ return out;
11662
+ }
11260
11663
 
11261
11664
  // src/core/build.ts
11262
- var REGISTRY = `localhost:${CURRENT_PORT}`;
11263
11665
  var PREVIOUS_REGISTRY = `localhost:${PREVIOUS_PORT}`;
11666
+ function tagsOf(options) {
11667
+ return [`build-${options.hash}`, ...options.registry.kind === "own" ? [options.sha] : [], ...options.tags ?? []];
11668
+ }
11264
11669
  function buildArgs(options, context, metadataFile, previousAnswers) {
11265
- const { app, hash, sha } = options;
11266
- const ref = (tag) => `${REGISTRY}/${app}:${tag}`;
11267
- const tags = [`build-${hash}`, sha, ...options.tags ?? []].flatMap((t) => ["--tag", ref(t)]);
11670
+ const { app } = options;
11671
+ const ref = (tag) => candidateRef(options.registry, app, tag);
11672
+ const tags = tagsOf(options).flatMap((t) => ["--tag", ref(t)]);
11268
11673
  const cache = options.noCache ? ["--no-cache"] : [
11269
11674
  "--cache-from",
11270
11675
  `type=registry,ref=${ref("cache")}`,
@@ -11283,7 +11688,7 @@ function buildArgs(options, context, metadataFile, previousAnswers) {
11283
11688
  "--build-arg",
11284
11689
  `APP=${app}`,
11285
11690
  "--build-arg",
11286
- `BUILD_HASH=${hash}`,
11691
+ `BUILD_HASH=${options.hash}`,
11287
11692
  "--secret",
11288
11693
  "id=next-server-actions-encryption-key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
11289
11694
  ...cache,
@@ -11300,159 +11705,71 @@ function buildArgs(options, context, metadataFile, previousAnswers) {
11300
11705
  "plain"
11301
11706
  ];
11302
11707
  }
11708
+ async function ensureBuilder(ctx, name) {
11709
+ if (!ctx.dryRun) {
11710
+ try {
11711
+ await ctx.read("docker", ["buildx", "inspect", name]);
11712
+ return;
11713
+ } catch {
11714
+ }
11715
+ }
11716
+ await ctx.apply([
11717
+ run("docker", [
11718
+ "buildx",
11719
+ "create",
11720
+ "--name",
11721
+ name,
11722
+ "--driver",
11723
+ "docker-container",
11724
+ "--driver-opt",
11725
+ "network=host",
11726
+ "--bootstrap"
11727
+ ])
11728
+ ]);
11729
+ }
11303
11730
  async function build(ctx, options) {
11304
11731
  if (!options.encryptionKey) throw new UsageError("NEXT_SERVER_ACTIONS_ENCRYPTION_KEY is empty");
11305
11732
  const dir = options.dir ?? ".";
11306
- const out = join(tmpdir(), `plx-ci-build-${randomBytes(4).toString("hex")}`);
11307
- const context = join(out, "full");
11733
+ const out = join2(tmpdir(), `plx-ci-build-${randomBytes(4).toString("hex")}`);
11734
+ const context = join2(out, "full");
11735
+ const builder = options.builder ?? "ci";
11308
11736
  const env = {
11309
11737
  NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: options.encryptionKey,
11310
- BUILDX_BUILDER: options.builder ?? "ci",
11738
+ BUILDX_BUILDER: builder,
11311
11739
  SOURCE_DATE_EPOCH: "0"
11312
11740
  };
11313
- const pkg = JSON.parse(await ctx.readFile(join(dir, "package.json")));
11314
- const turbo = pkg.devDependencies?.turbo ?? "latest";
11741
+ const name = await packageName(ctx, dir, options.app);
11742
+ await ensureBuilder(ctx, builder);
11315
11743
  let t = Date.now();
11316
11744
  await ctx.apply([
11317
- run("npx", ["--yes", `turbo@${turbo}`, "prune", `${options.scope}/${options.app}`, "--docker", "--out-dir", out], {
11745
+ run("npx", ["--yes", await turboOf(ctx, dir), "prune", name, "--docker", "--out-dir", out], {
11318
11746
  cwd: dir
11319
11747
  }),
11320
- run("cp", [join(out, "pnpm-lock.yaml"), join(context, "pnpm-lock.yaml")]),
11321
- run("cp", [join(dir, ".dockerignore"), join(context, ".dockerignore")]),
11322
- run("mkdir", ["-p", join(context, "packages")])
11748
+ run("cp", [join2(out, "pnpm-lock.yaml"), join2(context, "pnpm-lock.yaml")]),
11749
+ run("cp", [join2(dir, ".dockerignore"), join2(context, ".dockerignore")]),
11750
+ run("mkdir", ["-p", join2(context, "packages")])
11323
11751
  ]);
11324
11752
  const pruneSeconds = Math.round((Date.now() - t) / 1e3);
11325
11753
  ctx.say(`build: prune ${pruneSeconds}s`);
11326
- const previousAnswers = ctx.dryRun ? true : await answers(ctx, PREVIOUS_PORT);
11327
- if (!previousAnswers) ctx.say(`build: no previous-month registry on ${PREVIOUS_REGISTRY}`);
11328
- const metadataFile = join(out, "metadata.json");
11754
+ const previousAnswers = options.registry.kind === "own" && (ctx.dryRun ? true : await answers(ctx, PREVIOUS_PORT));
11755
+ if (options.registry.kind === "own" && !previousAnswers)
11756
+ ctx.say(`build: no previous-month registry on ${PREVIOUS_REGISTRY}`);
11757
+ const metadataFile = join2(out, "metadata.json");
11329
11758
  t = Date.now();
11330
11759
  await ctx.apply([run("docker", buildArgs(options, context, metadataFile, previousAnswers), { env })]);
11331
11760
  const buildSeconds = Math.round((Date.now() - t) / 1e3);
11332
11761
  ctx.say(`build: build${options.noCache ? " no-cache" : ""} ${buildSeconds}s`);
11333
- const tags = [`build-${options.hash}`, options.sha, ...options.tags ?? []];
11762
+ const tags = tagsOf(options);
11334
11763
  const result = { app: options.app, tags, pruneSeconds, buildSeconds };
11335
11764
  if (!ctx.dryRun) {
11336
11765
  const meta = JSON.parse(await ctx.readFile(metadataFile));
11337
11766
  result.digest = meta["containerimage.digest"];
11338
- ctx.say(`build: ${options.app} build-${options.hash} ${options.sha} -> ${result.digest ?? "<no digest>"}`);
11767
+ ctx.say(`build: ${options.app} ${tags.join(" ")} -> ${result.digest ?? "<no digest>"}`);
11339
11768
  }
11340
11769
  await ctx.apply([run("rm", ["-rf", out])]);
11341
11770
  return result;
11342
11771
  }
11343
11772
 
11344
- // src/commands/build.ts
11345
- function register(program2) {
11346
- program2.command("build").description("build one app\u2019s image from its pruned context against the registry sidecars and push it").argument("<app>", "the app, apps/<app> in the checkout").argument("<hash>", "Turbo\u2019s hash of the app\u2019s build task").argument("<sha>", "the commit, a second tag").argument("[tags...]", "further tags on the same image").option("--scope <scope>", "the npm scope of the workspace packages", "@damage-ms").option("-C, --dir <dir>", "the checkout to read", ".").action(
11347
- async (app, hash, sha, tags, opts, command) => {
11348
- const ctx = contextOf(command);
11349
- const result = await build(ctx, {
11350
- app,
11351
- hash,
11352
- sha,
11353
- tags,
11354
- encryptionKey: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ?? "",
11355
- noCache: process.env.NO_CACHE === "1",
11356
- dir: resolve(opts.dir),
11357
- scope: opts.scope,
11358
- builder: process.env.BUILDX_BUILDER
11359
- });
11360
- ctx.result(result, (r) => r.digest ?? "");
11361
- if (process.env.GITHUB_OUTPUT && result.digest)
11362
- appendFileSync(process.env.GITHUB_OUTPUT, `digest=${result.digest}
11363
- `);
11364
- }
11365
- );
11366
- }
11367
-
11368
- // src/core/inspect.ts
11369
- import { join as join2 } from "node:path";
11370
- var LOGS = [
11371
- ["journal-previous-boot.log", "sudo journalctl -b -1 --no-pager -o short-iso"],
11372
- [
11373
- "journal-previous-boot-network.log",
11374
- 'sudo journalctl -b -1 --no-pager -o short-iso -u systemd-networkd -u cloud-init-local -u cloud-init -u "ifup@*" -u networking -k'
11375
- ],
11376
- ["cloud-init.log", "sudo cat /var/log/cloud-init.log"],
11377
- ["cloud-init-output.log", "sudo cat /var/log/cloud-init-output.log"],
11378
- [
11379
- "system.txt",
11380
- "sudo journalctl --list-boots --no-pager; echo; cat /etc/cloud/cloud.cfg.d/*.cfg 2>/dev/null; echo; ls -la /etc/network/interfaces.d/ /etc/systemd/network/ /run/systemd/network/ 2>/dev/null; echo; cat /etc/network/interfaces.d/* /etc/systemd/network/* /run/systemd/network/* 2>/dev/null; echo; ip -br link; ip -br addr"
11381
- ]
11382
- ];
11383
- function sshArgs(key, ip, command) {
11384
- return [
11385
- "-i",
11386
- key,
11387
- "-o",
11388
- "BatchMode=yes",
11389
- "-o",
11390
- "ConnectTimeout=5",
11391
- "-o",
11392
- "StrictHostKeyChecking=no",
11393
- "-o",
11394
- "UserKnownHostsFile=/dev/null",
11395
- "-o",
11396
- "LogLevel=ERROR",
11397
- `debian@${ip}`,
11398
- command
11399
- ];
11400
- }
11401
- async function inspect(ctx, exo, options) {
11402
- const instance = await exo.instance(options.id);
11403
- if (!instance) throw new UsageError(`no instance ${options.id} in ${exo.zone}`);
11404
- if (!instance.publicIp) throw new UsageError(`instance ${options.id} has no public address`);
11405
- const ip = instance.publicIp;
11406
- const out = options.out ?? `inspect-${options.id}`;
11407
- const ssh = (command) => ctx.read("ssh", sshArgs(options.sshKey, ip, command));
11408
- ctx.say(`inspect: ${options.id} at ${ip}, state ${instance.state}`);
11409
- if (instance.state === "stopped") {
11410
- ctx.say("inspect: starting (the launch stopped it when it kept it)");
11411
- await exo.startInstance(options.id);
11412
- } else {
11413
- const before = await ssh("true").then(
11414
- () => true,
11415
- () => false
11416
- );
11417
- ctx.say(
11418
- before ? " SSH answers before the reboot (not a stall?)" : " no SSH before the reboot, as expected of a stalled boot"
11419
- );
11420
- ctx.say("inspect: rebooting");
11421
- await exo.rebootInstance(options.id);
11422
- }
11423
- if (ctx.dryRun) {
11424
- ctx.say(` (then: wait for SSH, mask plexus-ci-runner and the timers, copy the previous boot's logs to ${out}/)`);
11425
- return out;
11426
- }
11427
- let reached = false;
11428
- for (let i = 0; i < 60 && !reached; i++) {
11429
- await sleep(2e3);
11430
- reached = await ssh("true").then(
11431
- () => true,
11432
- () => false
11433
- );
11434
- }
11435
- if (!reached) throw new Error("no SSH after the reboot");
11436
- ctx.say("inspect: in, masking the units");
11437
- await ssh(
11438
- "sudo systemctl mask --runtime --now plexus-ci-runner.service plexus-ci-no-job.timer plexus-ci-death.timer plexus-ci-poweroff.timer 2>&1 | tail -2; sudo systemctl stop plexus-ci-runner.service 2>/dev/null || true"
11439
- );
11440
- ctx.say("inspect: copying the logs");
11441
- await ctx.apply([run("mkdir", ["-p", out])]);
11442
- for (const [file, command] of LOGS) {
11443
- let text;
11444
- try {
11445
- text = await ssh(command);
11446
- } catch (error) {
11447
- text = error instanceof ExecError ? `${error.message}
11448
- ` : String(error);
11449
- }
11450
- await ctx.apply([write(join2(out, file), text)]);
11451
- }
11452
- ctx.say(`inspect: done; the instance ${options.id} is still running, delete it when finished`);
11453
- return out;
11454
- }
11455
-
11456
11773
  // src/core/github.ts
11457
11774
  var GitHubRunners = class {
11458
11775
  ctx;
@@ -11536,60 +11853,145 @@ function githubOf(ctx, env = process.env) {
11536
11853
  return env.RUNNER_TOKEN ? new GitHubRunners(ctx, need("GITHUB_REPOSITORY", env), env.RUNNER_TOKEN) : void 0;
11537
11854
  }
11538
11855
  var ownerOf = (repo) => repo.split("/")[0] ?? repo;
11539
-
11540
- // src/commands/inspect.ts
11541
- function register2(program2) {
11542
- program2.command("inspect").description("start or reboot a kept instance, mask its runner and timers, and copy the previous boot\u2019s logs here").argument("<id>", "the instance id").option("--ssh-key <path>", "the private half of the key the instances get", `${process.env.HOME}/.ssh/deploy`).option("-o, --out <dir>", "where the logs land (default: ./inspect-<id>)").action(async (id, opts, command) => {
11543
- const ctx = contextOf(command);
11544
- const out = await inspect(ctx, exoOf(ctx), { id, sshKey: opts.sshKey, out: opts.out });
11545
- ctx.result(out);
11546
- });
11856
+ function withStrategy(command) {
11857
+ return command.option("--strategy <strategy>", "github, on-prem or hybrid (default: CI_STRATEGY, else github)");
11858
+ }
11859
+ function settings(opts, env = process.env) {
11860
+ return settingsOf(env, opts.strategy);
11861
+ }
11862
+ function imagePrefix(env = process.env) {
11863
+ return env.IMAGE_PREFIX || `ghcr.io/${ownerOf(need("GITHUB_REPOSITORY", env))}`;
11864
+ }
11865
+ function registryFor(s, env = process.env) {
11866
+ return registryOf(s.registry, s.registry === "ghcr" ? imagePrefix(env) : "");
11547
11867
  }
11548
11868
 
11549
- // src/commands/launch.ts
11550
- import { appendFileSync as appendFileSync2 } from "node:fs";
11869
+ // src/commands/build.ts
11870
+ function register(program2) {
11871
+ withStrategy(program2.command("build")).description("build one app\u2019s image from its pruned context against the registry and push it").argument("<app>", "the app, apps/<app> in the checkout").argument("<hash>", "Turbo\u2019s hash of the app\u2019s build task").argument("<sha>", "the commit, a second tag").argument("[tags...]", "further tags on the same image").option("-C, --dir <dir>", "the checkout to read", ".").action(
11872
+ async (app, hash, sha, tags, opts, command) => {
11873
+ const ctx = contextOf(command);
11874
+ const result = await build(ctx, {
11875
+ app,
11876
+ hash,
11877
+ sha,
11878
+ tags,
11879
+ encryptionKey: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ?? "",
11880
+ noCache: process.env.NO_CACHE === "1",
11881
+ dir: resolve(opts.dir),
11882
+ registry: registryFor(settings(opts)),
11883
+ builder: process.env.BUILDX_BUILDER
11884
+ });
11885
+ ctx.result(result, (r) => r.digest ?? "");
11886
+ if (process.env.GITHUB_OUTPUT && result.digest)
11887
+ appendFileSync(process.env.GITHUB_OUTPUT, `digest=${result.digest}
11888
+ `);
11889
+ }
11890
+ );
11891
+ }
11551
11892
 
11552
- // src/core/launch.ts
11893
+ // src/core/createInstances.ts
11553
11894
  import { gzipSync } from "node:zlib";
11554
11895
 
11555
- // src/core/teardown.ts
11896
+ // src/core/destroyInstances.ts
11556
11897
  var repoLabel = (repo) => repo.replace("/", "-");
11557
- function selectRunInstances(instances, run2, keep2 = []) {
11898
+ var KEPT_LABEL = "kept";
11899
+ function selectRunInstances(instances, run2) {
11558
11900
  return instances.filter(
11559
- (i) => i.labels["plexus-ci"] === "true" && i.labels.repo === repoLabel(run2.repo) && i.labels.run === run2.runId && i.labels.attempt === run2.attempt && !keep2.includes(i.id)
11901
+ (i) => i.labels["plexus-ci"] === "true" && i.labels.repo === repoLabel(run2.repo) && i.labels.run === run2.runId && i.labels.attempt === run2.attempt && i.labels[KEPT_LABEL] === void 0
11560
11902
  );
11561
11903
  }
11562
11904
  function selectRunRegistrations(runners, run2) {
11563
11905
  const pattern = new RegExp(`^exo-[a-z]+-${run2.runId}(-[^-]+)*-${run2.attempt}(-[a-z0-9]+)?$`);
11564
11906
  return runners.filter((r) => !r.busy && pattern.test(r.name));
11565
11907
  }
11566
- async function teardown(ctx, exo, github, run2, keep2 = []) {
11908
+ async function destroyInstances(ctx, exo, github, run2, { retries = 12, wait = sleep } = {}) {
11567
11909
  const all = await exo.listInstances();
11568
- const mine = selectRunInstances(all, run2, keep2);
11569
- const kept = all.filter((i) => keep2.includes(i.id)).map((i) => i.name);
11570
- for (const name of kept) ctx.say(`${name}: kept for inspection`);
11571
- for (const i of mine) {
11572
- ctx.say(`${i.name} (${i.id}): ${i.state}`);
11573
- await exo.deleteInstance(i.id);
11574
- }
11575
- ctx.say(`teardown: ${mine.length} instance(s) of run ${run2.runId} attempt ${run2.attempt}`);
11576
- const registrations = [];
11577
- if (github) {
11910
+ const mine = selectRunInstances(all, run2);
11911
+ const kept = all.filter(
11912
+ (i) => i.labels["plexus-ci"] === "true" && i.labels[KEPT_LABEL] !== void 0 && i.labels.repo === repoLabel(run2.repo) && i.labels.run === run2.runId
11913
+ ).map((i) => i.name);
11914
+ for (const name of kept) ctx.say(`${name}: kept after a missed boot, left alone`);
11915
+ let left = mine;
11916
+ for (let round = 0; left.length > 0 && round <= retries; round++) {
11917
+ if (round > 0) await wait(1e4);
11918
+ const refused = [];
11919
+ for (const i of left) {
11920
+ if (round === 0) ctx.say(`${i.name} (${i.id}): ${i.state}`);
11921
+ try {
11922
+ await exo.deleteInstance(i.id);
11923
+ } catch (error) {
11924
+ if (round === retries) ctx.say(`\u2717 ${i.name} (${i.id}) is not deleted: ${error.message}`);
11925
+ refused.push(i);
11926
+ }
11927
+ }
11928
+ left = refused;
11929
+ }
11930
+ ctx.say(
11931
+ `destroy-instances: ${mine.length - left.length} of ${mine.length} instance(s) of run ${run2.runId} attempt ${run2.attempt} deleted`
11932
+ );
11933
+ const registrations = [];
11934
+ if (github) {
11578
11935
  for (const r of selectRunRegistrations(await github.list(), run2)) {
11579
11936
  ctx.say(`runner ${r.name} (${r.id}): ${r.status}`);
11580
11937
  await github.deleteRunner(r.id);
11581
11938
  registrations.push(r.name);
11582
11939
  }
11583
- ctx.say(`teardown: ${registrations.length} registration(s) removed`);
11940
+ ctx.say(`destroy-instances: ${registrations.length} registration(s) removed`);
11584
11941
  }
11942
+ if (left.length > 0)
11943
+ throw new Error(
11944
+ `${left.length} instance(s) could not be deleted: ${left.map((i) => i.name).join(", ")}; they power off on their own, and the sweep deletes them`
11945
+ );
11585
11946
  return { instances: mine.map((i) => i.name), kept, registrations };
11586
11947
  }
11587
11948
 
11949
+ // src/core/mail.ts
11950
+ import { tmpdir as tmpdir2 } from "node:os";
11951
+ import { join as join3 } from "node:path";
11952
+ function smtpFromEnv(env = process.env) {
11953
+ const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, MAIL_FROM, MAIL_TO } = env;
11954
+ if (!SMTP_HOST || !SMTP_USER || !SMTP_PASSWORD || !MAIL_FROM || !MAIL_TO) return void 0;
11955
+ return {
11956
+ host: SMTP_HOST,
11957
+ port: SMTP_PORT || "587",
11958
+ user: SMTP_USER,
11959
+ password: SMTP_PASSWORD,
11960
+ from: MAIL_FROM,
11961
+ to: MAIL_TO
11962
+ };
11963
+ }
11964
+ function message(smtp, subject, text, date) {
11965
+ const body2 = text.replaceAll("\r\n", "\n").replaceAll("\n", "\r\n");
11966
+ return `From: plexus-ci <${smtp.from}>\r
11967
+ To: ${smtp.to}\r
11968
+ Subject: ${subject}\r
11969
+ Date: ${date.toUTCString()}\r
11970
+ \r
11971
+ ${body2}\r
11972
+ `;
11973
+ }
11974
+ async function sendMail(ctx, smtp, subject, text, date = /* @__PURE__ */ new Date()) {
11975
+ const scheme = smtp.port === "465" || smtp.port === "2465" ? "smtps" : "smtp";
11976
+ const file = join3(tmpdir2(), `plx-ci-mail-${process.pid}.eml`);
11977
+ const config = [
11978
+ `url = "${scheme}://${smtp.host}:${smtp.port}"`,
11979
+ "ssl-reqd",
11980
+ "silent",
11981
+ "show-error",
11982
+ `mail-from = "${smtp.from}"`,
11983
+ `mail-rcpt = "${smtp.to}"`,
11984
+ `user = ${JSON.stringify(`${smtp.user}:${smtp.password}`)}`,
11985
+ `upload-file = ${JSON.stringify(file)}`
11986
+ ].join("\n");
11987
+ await ctx.apply([write(file, message(smtp, subject, text, date)), run("curl", ["-K", "-"], { input: config })]);
11988
+ }
11989
+
11588
11990
  // src/core/userData.ts
11589
11991
  var RUNNER_USER_DATA = `#cloud-config
11590
- # One throwaway CI runner (ci.md, "Instances"). Rendered by launch.sh, which
11591
- # fills the placeholders. Every teardown layer is armed here, before the
11592
- # runner exists:
11992
+ # One throwaway CI runner (PLX-Delivery, \xA7 8). Rendered by plx-ci hybrid
11993
+ # create-instances, which fills the placeholders. Every teardown layer is
11994
+ # armed here, before the runner exists:
11593
11995
  #
11594
11996
  # layer 1 the runner exits after its one job, the instance deletes itself
11595
11997
  # layer 1b noJobTimeout, @@NO_JOB_MINUTES@@ minutes after boot with no job ever started,
@@ -11605,7 +12007,7 @@ var RUNNER_USER_DATA = `#cloud-config
11605
12007
  #
11606
12008
  # The self-delete key and the registry bucket's write key sit in
11607
12009
  # /etc/plexus-ci/env, readable by root; the job that runs here could read
11608
- # them anyway (ci.md, "Secrets").
12010
+ # them anyway (\xA7 8 PLX-Delivery).
11609
12011
  hostname: @@LABEL@@
11610
12012
 
11611
12013
  write_files:
@@ -11644,7 +12046,7 @@ write_files:
11644
12046
  # The env file is sourced exported (set -a): exo reads the key from the
11645
12047
  # environment, and a plain source would leave it shell-local. A timer's
11646
12048
  # service has no HOME, and exo refuses to run without one (layers 1b and
11647
- # 2b failed on it on 2026-11-04, ci.md); the runner's unit has a user.
12049
+ # 2b failed on it on 2026-11-04); the runner's unit has a user.
11648
12050
  - path: /usr/local/sbin/plexus-ci-exo
11649
12051
  permissions: "0755"
11650
12052
  content: |
@@ -11688,7 +12090,7 @@ write_files:
11688
12090
  set -u
11689
12091
  set -a; . /etc/plexus-ci/env; set +a
11690
12092
  # SELF_LAYERS=off (the launch's SELF_LAYERS, while the boot stall is
11691
- # investigated, ci.md): layers 1, 1b and 2b log and do nothing; the
12093
+ # investigated): layers 1, 1b and 2b log and do nothing; the
11692
12094
  # power-off, the teardown job and the sweep remain.
11693
12095
  [ "\${SELF_LAYERS:-on}" != off ] || { logger -t plexus-ci "self-delete ($1): layers off, not deleting"; exit 0; }
11694
12096
  id=$(curl -fsS --max-time 10 http://metadata.exoscale.com/latest/meta-data/instance-id) || id=
@@ -11702,7 +12104,7 @@ write_files:
11702
12104
  #!/bin/sh
11703
12105
  # Layer 1b: the runner spawns a Worker process for a job and nothing
11704
12106
  # else, and logs it as _diag/Worker_*.log; _work itself exists empty
11705
- # from the runner's start (seen 2026-11-04, ci.md). No Worker log by
12107
+ # from the runner's start (seen 2026-11-04). No Worker log by
11706
12108
  # now means no job ever came, and nothing will.
11707
12109
  set -u
11708
12110
  if ! ls /opt/runner/_diag/Worker_*.log >/dev/null 2>&1; then
@@ -11771,6 +12173,32 @@ write_files:
11771
12173
  [Install]
11772
12174
  WantedBy=timers.target
11773
12175
 
12176
+ # Frees the dpkg lock for the run commands. The image is weeks old, so
12177
+ # apt-daily-upgrade's persistent timer catches up seconds into the boot, and
12178
+ # unattended-upgrades then holds the lock for about a minute: every apt-get
12179
+ # here failed at once, there was no docker and no runner user, and the
12180
+ # launch kept the instance as a stalled boot. Waiting on the lock costs that
12181
+ # minute, so the instance, which lives under an hour, masks the apt timers
12182
+ # and services (they never start after this), kills whatever of them
12183
+ # already runs, dpkg children included (systemctl kill takes the unit's
12184
+ # whole cgroup), and lets dpkg finish what a kill interrupted. Masking alone
12185
+ # is not enough: the catch-up can start before any run command does, and
12186
+ # bootcmd is out (see above).
12187
+ - path: /usr/local/sbin/plexus-ci-apt-free
12188
+ permissions: "0755"
12189
+ content: |
12190
+ #!/bin/sh
12191
+ units='apt-daily.timer apt-daily-upgrade.timer apt-daily.service apt-daily-upgrade.service unattended-upgrades.service'
12192
+ systemctl mask $units >/dev/null 2>&1
12193
+ systemctl kill --signal=KILL apt-daily.service apt-daily-upgrade.service unattended-upgrades.service 2>/dev/null
12194
+ i=0
12195
+ while pgrep -x 'unattended-upgr|apt\\.systemd\\.dai|apt-get|apt|dpkg' >/dev/null && [ "$i" -lt 10 ]; do
12196
+ sleep 1; i=$((i + 1))
12197
+ done
12198
+ systemctl stop $units 2>/dev/null
12199
+ dpkg --configure -a
12200
+ logger -t plexus-ci "apt-free: apt timers masked, dpkg lock free after \${i}s"
12201
+
11774
12202
  # The runner, one job, then layer 1: ExecStopPost runs as root (the +) when
11775
12203
  # the runner exits for any reason.
11776
12204
  - path: /etc/systemd/system/plexus-ci-runner.service
@@ -11793,45 +12221,21 @@ runcmd:
11793
12221
  # Layers 1b, 2b and 2a first, before anything that can hang.
11794
12222
  - systemctl daemon-reload
11795
12223
  - systemctl start plexus-ci-no-job.timer plexus-ci-death.timer plexus-ci-poweroff.timer
12224
+ - plexus-ci-apt-free
11796
12225
  - export DEBIAN_FRONTEND=noninteractive; apt-get update -q && apt-get install -yq --no-install-recommends ca-certificates curl jq git tar unzip
11797
12226
  - curl -fsSL https://get.docker.com | sh
11798
12227
  - useradd -m -s /bin/bash -G docker runner && echo 'runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/runner
11799
12228
  - cp /etc/plexus-ci/jitconfig /etc/plexus-ci/jitconfig.runner && chown runner:runner /etc/plexus-ci/jitconfig.runner && mv /etc/plexus-ci/jitconfig.runner /etc/plexus-ci/jitconfig
11800
12229
  - . /etc/plexus-ci/env && mkdir -p /opt/runner && cd /opt/runner && curl -fsSLo runner.tgz "https://github.com/actions/runner/releases/download/v\${RUNNER_VERSION}/actions-runner-linux-x64-\${RUNNER_VERSION}.tar.gz" && echo "\${RUNNER_SHA256} runner.tgz" | sha256sum -c - && tar -xzf runner.tgz && rm runner.tgz && ./bin/installdependencies.sh >/dev/null && chown -R runner:runner /opt/runner
11801
12230
  - plexus-ci-exo version >/dev/null
11802
- # The registry sidecars, when the launch named a bucket (ci.md, "The registry"): :5000 on this month's bucket, read-write; :5001 on the previous month's, read-only. The same two containers as .github/ci/registry.sh starts on a GitHub runner.
12231
+ # The registry sidecars, when the launch named a bucket (\xA7 8 PLX-Delivery): :5000 on this month's bucket, read-write; :5001 on the previous month's, read-only. The same two containers as .github/ci/registry.sh starts on a GitHub runner.
11803
12232
  - . /etc/plexus-ci/env && if [ -n "$REGISTRY_ENDPOINT" ]; then docker run -d --name registry --restart unless-stopped -p 127.0.0.1:5000:5000 -e REGISTRY_STORAGE=s3 -e REGISTRY_STORAGE_S3_REGIONENDPOINT="$REGISTRY_ENDPOINT" -e REGISTRY_STORAGE_S3_REGION="$REGISTRY_REGION" -e REGISTRY_STORAGE_S3_BUCKET="$REGISTRY_BUCKET" -e REGISTRY_STORAGE_S3_ACCESSKEY="$REGISTRY_ACCESS_KEY" -e REGISTRY_STORAGE_S3_SECRETKEY="$REGISTRY_SECRET_KEY" -e REGISTRY_STORAGE_S3_FORCEPATHSTYLE=true -e REGISTRY_STORAGE_DELETE_ENABLED=true registry:2 >/dev/null; fi
11804
- - . /etc/plexus-ci/env && if [ -n "$REGISTRY_ENDPOINT" ]; then docker run -d --name registry-previous --restart on-failure:3 -p 127.0.0.1:5001:5000 -e REGISTRY_STORAGE=s3 -e REGISTRY_STORAGE_S3_REGIONENDPOINT="$REGISTRY_ENDPOINT" -e REGISTRY_STORAGE_S3_REGION="$REGISTRY_REGION" -e REGISTRY_STORAGE_S3_BUCKET="$REGISTRY_PREVIOUS_BUCKET" -e REGISTRY_STORAGE_S3_ACCESSKEY="$REGISTRY_ACCESS_KEY" -e REGISTRY_STORAGE_S3_SECRETKEY="$REGISTRY_SECRET_KEY" -e REGISTRY_STORAGE_S3_FORCEPATHSTYLE=true -e REGISTRY_STORAGE_MAINTENANCE_READONLY_ENABLED=true registry:2 >/dev/null; fi
12233
+ - . /etc/plexus-ci/env && if [ -n "$REGISTRY_ENDPOINT" ]; then docker run -d --name registry-previous --restart on-failure:3 -p 127.0.0.1:5001:5000 -e REGISTRY_STORAGE=s3 -e REGISTRY_STORAGE_S3_REGIONENDPOINT="$REGISTRY_ENDPOINT" -e REGISTRY_STORAGE_S3_REGION="$REGISTRY_REGION" -e REGISTRY_STORAGE_S3_BUCKET="$REGISTRY_PREVIOUS_BUCKET" -e REGISTRY_STORAGE_S3_ACCESSKEY="$REGISTRY_ACCESS_KEY" -e REGISTRY_STORAGE_S3_SECRETKEY="$REGISTRY_SECRET_KEY" -e REGISTRY_STORAGE_S3_FORCEPATHSTYLE=true -e 'REGISTRY_STORAGE_MAINTENANCE_READONLY={"enabled":true}' registry:2 >/dev/null; fi
11805
12234
  # A builder: the docker driver cannot export a registry cache, so BuildKit runs in its own container on the host's network, where localhost:5000 is the sidecar; bootstrapped now so that the build does not wait for it.
11806
12235
  - . /etc/plexus-ci/env && if [ "$KIND" = build ]; then su -s /bin/bash runner -c 'docker buildx create --name ci --driver docker-container --driver-opt network=host --bootstrap >/dev/null' & fi
11807
12236
  - . /etc/plexus-ci/env && if [ "$KIND" = test ]; then docker pull -q postgres:18.6 >/dev/null & docker pull -q rustfs/rustfs:1.0.4 >/dev/null & fi
11808
12237
  - systemctl start plexus-ci-runner.service
11809
12238
  `;
11810
- var BARE_USER_DATA = `#cloud-config
11811
- # The bisect probe (ci-dummy with break = bare): nothing but the power-off
11812
- # timer, so that a boot with this and a boot with user-data.yaml tell apart
11813
- # the image from the scripts. The sweep removes a powered-off instance.
11814
- write_files:
11815
- - path: /etc/systemd/system/plexus-ci-poweroff.service
11816
- content: |
11817
- [Unit]
11818
- Description=plexus-ci: power off at the hard deadline
11819
- [Service]
11820
- Type=oneshot
11821
- ExecStart=/usr/bin/systemctl poweroff
11822
- - path: /etc/systemd/system/plexus-ci-poweroff.timer
11823
- content: |
11824
- [Unit]
11825
- Description=plexus-ci: layer 2a
11826
- [Timer]
11827
- OnBootSec=@@POWEROFF_MINUTES@@min
11828
- AccuracySec=10s
11829
- [Install]
11830
- WantedBy=timers.target
11831
- runcmd:
11832
- - systemctl daemon-reload
11833
- - systemctl start plexus-ci-poweroff.timer
11834
- `;
11835
12239
  function placeholders(template) {
11836
12240
  return [...new Set([...template.matchAll(/@@([A-Z0-9_]+)@@/g)].map((m) => m[1] ?? ""))].filter((n) => n !== "");
11837
12241
  }
@@ -11842,7 +12246,7 @@ function render2(template, values) {
11842
12246
  return text;
11843
12247
  }
11844
12248
 
11845
- // src/core/launch.ts
12249
+ // src/core/createInstances.ts
11846
12250
  var DEFAULTS = {
11847
12251
  zone: "de-fra-1",
11848
12252
  instanceType: "cpu.extra-large",
@@ -11859,8 +12263,6 @@ var DEFAULTS = {
11859
12263
  userDataLimit: 32768
11860
12264
  };
11861
12265
  function renderUserData(options, jitconfig, now) {
11862
- if (options.kind === "bare")
11863
- return render2(BARE_USER_DATA, { POWEROFF_MINUTES: String(options.deadlines.poweroffMinutes) });
11864
12266
  const owner = options.run.repo.split("/")[0] ?? options.run.repo;
11865
12267
  const buckets = registryBuckets(owner, now);
11866
12268
  return render2(RUNNER_USER_DATA, {
@@ -11914,44 +12316,49 @@ function instanceLabels(options, noJobDeadline, deathDeadline) {
11914
12316
  }
11915
12317
  async function launch(ctx, exo, github, options) {
11916
12318
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
11917
- const { label, kind } = options;
12319
+ const { label } = options;
11918
12320
  if (options.deadlines.poweroffMinutes <= options.deadlines.deathMinutes)
11919
12321
  throw new UsageError("the power-off must come after the death deadline");
11920
12322
  const t0 = now();
11921
12323
  const noJobDeadline = deadline(t0, options.deadlines.noJobMinutes);
11922
12324
  const deathDeadline = deadline(t0, options.deadlines.deathMinutes);
11923
- const registration = kind === "bare" ? void 0 : await github.generateJitConfig(label, label);
11924
- const jitconfig = registration?.encodedJitConfig ?? "<jitconfig>";
11925
- const unregister = async () => {
11926
- if (!registration) return;
11927
- ctx.say(`launch failed, removing the runner registration ${label} (${registration.runnerId})`);
11928
- await github.deleteRunner(registration.runnerId).catch(() => void 0);
12325
+ const labels = instanceLabels(options, noJobDeadline, deathDeadline);
12326
+ const [templateId, instanceTypeId, securityGroupId] = await Promise.all([
12327
+ exo.templateId(options.template),
12328
+ exo.instanceTypeId(options.instanceType),
12329
+ exo.securityGroupId(options.securityGroup)
12330
+ ]);
12331
+ const result = {
12332
+ id: "<instance-id>",
12333
+ label,
12334
+ createdAt: iso(t0),
12335
+ noJobDeadline,
12336
+ deathDeadline,
12337
+ tries: 0,
12338
+ kept: []
11929
12339
  };
11930
- try {
11931
- const userData = encodeUserData(renderUserData(options, jitconfig, t0));
11932
- const [templateId, instanceTypeId, securityGroupId] = await Promise.all([
11933
- exo.templateId(options.template),
11934
- exo.instanceTypeId(options.instanceType),
11935
- exo.securityGroupId(options.securityGroup)
11936
- ]);
11937
- const request = {
11938
- name: label,
11939
- templateId,
11940
- instanceTypeId,
11941
- securityGroupId,
11942
- sshKey: options.sshKey,
11943
- diskSize: options.diskSize,
11944
- userDataBase64: userData,
11945
- labels: instanceLabels(options, noJobDeadline, deathDeadline)
12340
+ ctx.say(`\u2192 creating ${label} (${options.instanceType}, ${options.zone})`);
12341
+ for (let attempt = 1; attempt <= options.tries; attempt++) {
12342
+ const registration = await github.generateJitConfig(label, label);
12343
+ const unregister = async () => {
12344
+ if (registration) await github.deleteRunner(registration.runnerId).catch(() => void 0);
11946
12345
  };
11947
- const result = { id: "<instance-id>", label, createdAt: iso(t0), noJobDeadline, deathDeadline, tries: 0 };
11948
- ctx.say(`\u2192 creating ${label} (${options.instanceType}, ${options.zone})`);
11949
- for (let attempt = 1; attempt <= options.tries; attempt++) {
12346
+ try {
12347
+ const request = {
12348
+ name: label,
12349
+ templateId,
12350
+ instanceTypeId,
12351
+ securityGroupId,
12352
+ sshKey: options.sshKey,
12353
+ diskSize: options.diskSize,
12354
+ userDataBase64: encodeUserData(renderUserData(options, registration?.encodedJitConfig ?? "<jitconfig>", t0)),
12355
+ labels
12356
+ };
11950
12357
  const operation = await exo.createInstance(request);
11951
12358
  result.tries = attempt;
11952
12359
  if (operation === void 0) {
11953
12360
  ctx.say(
11954
- ` (then: wait up to ${options.onlineSeconds}s for the runner to show online, replace the instance on a miss)`
12361
+ ` (then: wait up to ${options.onlineSeconds}s for the runner to show online; on a miss keep the instance stopped as ${label}-kept, mail, and create one replacement)`
11955
12362
  );
11956
12363
  return result;
11957
12364
  }
@@ -11961,118 +12368,135 @@ async function launch(ctx, exo, github, options) {
11961
12368
  const state = await exo.waitOperation(operation.id);
11962
12369
  if (state !== "success") throw new Error(`create-instance ended in state ${state}`);
11963
12370
  ctx.say(`created ${label} (${result.id}) in ${options.zone}, try ${attempt}`);
11964
- if (kind === "bare") return result;
11965
- const waited = await waitOnline(github, label, options.onlineSeconds);
11966
- if (waited !== void 0) {
11967
- ctx.say(`runner ${label} online after ${waited}s`);
11968
- ctx.say(
11969
- `launched ${label} (${result.id}): no job by ${noJobDeadline}, dies at ${deathDeadline}, powers off ${options.deadlines.poweroffMinutes} minutes after creation`
11970
- );
11971
- return result;
11972
- }
11973
- ctx.say(`\u2717 ${label} (${result.id}) did not come online within ${options.onlineSeconds}s`);
11974
- if (options.diagSshKey) await diagnose2(ctx, exo, result.id, options.diagSshKey);
11975
- if (options.keepMissed && await keep(ctx, options.keepMissed, result.id)) {
11976
- await exo.stopInstance(result.id).catch(() => ctx.say(" stop-instance refused; the instance may delete itself"));
11977
- ctx.say(`kept ${label} (${result.id}) for inspection, stopped; run plx-ci inspect ${result.id}`);
11978
- result.kept = result.id;
11979
- } else {
11980
- ctx.say(`deleting ${label} (${result.id})`);
11981
- await exo.deleteInstance(result.id).catch(() => ctx.say(` delete of ${result.id} failed`));
11982
- }
12371
+ } catch (error) {
12372
+ ctx.say(`launch failed, removing the runner registration ${label}`);
12373
+ await unregister();
12374
+ throw error;
12375
+ }
12376
+ const waited = await waitOnline(github, label, options.onlineSeconds, options.sleep ?? sleep);
12377
+ if (waited !== void 0) {
12378
+ ctx.say(`runner ${label} online after ${waited}s`);
12379
+ ctx.say(
12380
+ `launched ${label} (${result.id}): no job by ${noJobDeadline}, dies at ${deathDeadline}, powers off ${options.deadlines.poweroffMinutes} minutes after creation`
12381
+ );
12382
+ return result;
11983
12383
  }
12384
+ ctx.say(`\u2717 ${label} (${result.id}) did not come online within ${options.onlineSeconds}s`);
12385
+ await keep(ctx, exo, options, result.id, labels, now());
12386
+ result.kept.push(result.id);
11984
12387
  await unregister();
11985
- throw new Error(`${options.tries} instance(s) of ${label} failed to boot, giving up`);
11986
- } catch (error) {
11987
- if (!(error instanceof Error && error.message.endsWith("giving up"))) await unregister();
11988
- throw error;
11989
12388
  }
12389
+ throw new Error(`${options.tries} instance(s) of ${label} failed to come online and are kept, giving up`);
11990
12390
  }
11991
- async function waitOnline(github, label, seconds) {
12391
+ async function waitOnline(github, label, seconds, wait) {
11992
12392
  for (let waited = 0; waited < seconds; waited += 5) {
11993
- await sleep(5e3);
12393
+ await wait(5e3);
11994
12394
  if (await github.status(label) === "online") return waited + 5;
11995
12395
  }
11996
12396
  return void 0;
11997
12397
  }
11998
- async function keep(ctx, file, id) {
11999
- try {
12000
- await ctx.stage(run("sh", ["-c", 'set -C && echo "$1" > "$2"', "keep", id, file]));
12001
- return true;
12002
- } catch (error) {
12003
- if (error instanceof ExecError) return false;
12004
- throw error;
12005
- }
12398
+ function keptMail(options, id) {
12399
+ const { label, run: run2 } = options;
12400
+ return {
12401
+ subject: `plexus-ci: ${label} did not come online and is kept`,
12402
+ text: [
12403
+ `The instance ${label} (${options.kind}, app ${options.app ?? "none"}) of ${run2.repo} run ${run2.runId} attempt ${run2.attempt} did not register its runner within ${options.onlineSeconds} seconds.`,
12404
+ "",
12405
+ `It is stopped and kept as ${label}-kept:`,
12406
+ ` id ${id}`,
12407
+ ` zone ${options.zone}`,
12408
+ "",
12409
+ "Its disk holds the boot it stalled in. Snapshot it to read it; the daily sweep mails again after seven days and deletes the instance after fourteen.",
12410
+ ""
12411
+ ].join("\n")
12412
+ };
12006
12413
  }
12007
- async function diagnose2(ctx, exo, id, sshKey) {
12008
- const ip = (await exo.instance(id))?.publicIp;
12009
- ctx.say(`diagnosis of ${id} at ${ip ?? "no ip"}:`);
12010
- if (!ip) return;
12011
- try {
12012
- const out = await ctx.read("ssh", [
12013
- "-i",
12014
- sshKey,
12015
- "-o",
12016
- "BatchMode=yes",
12017
- "-o",
12018
- "ConnectTimeout=10",
12019
- "-o",
12020
- "StrictHostKeyChecking=no",
12021
- "-o",
12022
- "UserKnownHostsFile=/dev/null",
12023
- "-o",
12024
- "LogLevel=ERROR",
12025
- `debian@${ip}`,
12026
- 'sudo cloud-init status --long 2>&1 | head -5; echo "-- runner unit"; sudo systemctl is-active plexus-ci-runner; sudo journalctl -u plexus-ci-runner --no-pager -o short-iso | tail -15; echo "-- plexus-ci"; sudo journalctl -t plexus-ci --no-pager -o short-iso | tail -5'
12027
- ]);
12028
- ctx.say(out.replace(/^/gm, " "));
12029
- } catch (error) {
12030
- ctx.say(` no SSH to ${ip}: ${error instanceof ExecError ? error.stderr.trim() : String(error)}`);
12414
+ async function keep(ctx, exo, options, id, labels, now) {
12415
+ const { label } = options;
12416
+ const stopped = await exo.stopInstance(id).then(
12417
+ () => true,
12418
+ () => false
12419
+ );
12420
+ if (!stopped) ctx.say(` stop-instance refused for ${id}; the instance may delete itself`);
12421
+ const marked = await exo.updateInstance(id, `${label}-kept`, { ...labels, [KEPT_LABEL]: iso(now) }).then(
12422
+ () => true,
12423
+ () => false
12424
+ );
12425
+ if (!marked) ctx.say(` update-instance refused for ${id}; unmarked, the cleanup will delete it`);
12426
+ ctx.say(`kept ${label} (${id}) as ${label}-kept${stopped ? ", stopped" : ""}`);
12427
+ if (!options.smtp) return;
12428
+ const mail = keptMail(options, id);
12429
+ await sendMail(ctx, options.smtp, mail.subject, mail.text, now).catch(
12430
+ () => ctx.say(` the mail about ${id} could not be sent`)
12431
+ );
12432
+ }
12433
+ async function createInstances(ctx, exo, github, legs, shared) {
12434
+ const launches = await Promise.allSettled(legs.map((leg) => launch(ctx, exo, github, { ...shared, ...leg })));
12435
+ const failed = launches.filter((l) => l.status === "rejected");
12436
+ if (failed.length > 0) {
12437
+ for (const f of failed) ctx.say(`\u2717 ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`);
12438
+ throw new Error(`${failed.length} launch(es) failed`);
12031
12439
  }
12440
+ return launches.flatMap((l) => l.status === "fulfilled" ? [l.value] : []);
12032
12441
  }
12033
12442
 
12034
- // src/core/mail.ts
12035
- import { tmpdir as tmpdir2 } from "node:os";
12036
- import { join as join3 } from "node:path";
12037
- function smtpFromEnv(env = process.env) {
12038
- const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, MAIL_FROM, MAIL_TO } = env;
12039
- if (!SMTP_HOST || !SMTP_USER || !SMTP_PASSWORD || !MAIL_FROM || !MAIL_TO) return void 0;
12040
- return {
12041
- host: SMTP_HOST,
12042
- port: SMTP_PORT || "587",
12043
- user: SMTP_USER,
12044
- password: SMTP_PASSWORD,
12045
- from: MAIL_FROM,
12046
- to: MAIL_TO
12047
- };
12443
+ // src/core/sweepInstances.ts
12444
+ var REMIND_AFTER_DAYS = 7;
12445
+ var DELETE_KEPT_AFTER_DAYS = 14;
12446
+ var DAY = 864e5;
12447
+ function verdictOn(instance, now) {
12448
+ const kept = instance.labels[KEPT_LABEL];
12449
+ if (kept !== void 0) {
12450
+ const days = (now.getTime() - Date.parse(kept)) / DAY;
12451
+ if (days >= DELETE_KEPT_AFTER_DAYS)
12452
+ return { action: "remove", reason: `kept since ${kept}, ${DELETE_KEPT_AFTER_DAYS} days` };
12453
+ if (days >= REMIND_AFTER_DAYS && days < REMIND_AFTER_DAYS + 1) return { action: "remind" };
12454
+ return { action: "stay" };
12455
+ }
12456
+ const reasons = [];
12457
+ if (instance.state === "stopped") reasons.push("stopped");
12458
+ const deadline2 = instance.labels["death-deadline"];
12459
+ if (deadline2 && deadline2 < iso(now)) reasons.push(`past its deadline ${deadline2}`);
12460
+ return reasons.length > 0 ? { action: "remove", reason: reasons.join(", ") } : { action: "stay" };
12048
12461
  }
12049
- function message(smtp, subject, text, date) {
12050
- const body2 = text.replaceAll("\r\n", "\n").replaceAll("\n", "\r\n");
12051
- return `From: plexus-ci <${smtp.from}>\r
12052
- To: ${smtp.to}\r
12053
- Subject: ${subject}\r
12054
- Date: ${date.toUTCString()}\r
12055
- \r
12056
- ${body2}\r
12462
+ async function sweepInstances(ctx, exo, options = {}) {
12463
+ const now = options.now ?? /* @__PURE__ */ new Date();
12464
+ const result = { removed: [], reminded: [], stayed: [] };
12465
+ for (const i of await exo.listInstances()) {
12466
+ if (i.labels["plexus-ci"] !== "true") continue;
12467
+ const verdict = verdictOn(i, now);
12468
+ if (verdict.action === "remove") {
12469
+ ctx.say(`${i.name} (${i.id}): ${verdict.reason}`);
12470
+ await exo.deleteInstance(i.id);
12471
+ result.removed.push({ name: i.name, reason: verdict.reason });
12472
+ continue;
12473
+ }
12474
+ const kept = i.labels[KEPT_LABEL];
12475
+ ctx.say(
12476
+ `${i.name} (${i.id}): ${i.state}, ${kept ? `kept since ${kept}` : `until ${i.labels["death-deadline"] ?? "-"}`}, stays`
12477
+ );
12478
+ result.stayed.push(i.name);
12479
+ if (verdict.action === "remind") result.reminded.push(`${i.name} (${i.id}), kept since ${kept}`);
12480
+ }
12481
+ if (options.smtp && result.removed.length > 0) {
12482
+ const text = `The daily sweep removed ${result.removed.length} plexus-ci instance(s) in ${exo.zone} that the other layers left behind:
12483
+
12484
+ ${result.removed.map((r) => `${r.name} (${r.reason})`).join("\n")}
12057
12485
  `;
12058
- }
12059
- async function sendMail(ctx, smtp, subject, text, date = /* @__PURE__ */ new Date()) {
12060
- const scheme = smtp.port === "465" || smtp.port === "2465" ? "smtps" : "smtp";
12061
- const file = join3(tmpdir2(), `plx-ci-mail-${process.pid}.eml`);
12062
- const config = [
12063
- `url = "${scheme}://${smtp.host}:${smtp.port}"`,
12064
- "ssl-reqd",
12065
- "silent",
12066
- "show-error",
12067
- `mail-from = "${smtp.from}"`,
12068
- `mail-rcpt = "${smtp.to}"`,
12069
- `user = ${JSON.stringify(`${smtp.user}:${smtp.password}`)}`,
12070
- `upload-file = ${JSON.stringify(file)}`
12071
- ].join("\n");
12072
- await ctx.apply([write(file, message(smtp, subject, text, date)), run("curl", ["-K", "-"], { input: config })]);
12486
+ await sendMail(ctx, options.smtp, "plexus-ci: the sweep removed instances", text, now);
12487
+ }
12488
+ if (options.smtp && result.reminded.length > 0) {
12489
+ const text = `These plexus-ci instance(s) in ${exo.zone} have been kept for ${REMIND_AFTER_DAYS} days after a missed boot, stopped, their disks billed. The sweep deletes each ${DELETE_KEPT_AFTER_DAYS} days after it was kept:
12490
+
12491
+ ${result.reminded.join("\n")}
12492
+ `;
12493
+ await sendMail(ctx, options.smtp, "plexus-ci: kept instances are still there", text, now);
12494
+ }
12495
+ ctx.say(`sweep-instances: ${result.removed.length} removed, ${result.reminded.length} reminded of`);
12496
+ return result;
12073
12497
  }
12074
12498
 
12075
- // src/commands/launch.ts
12499
+ // src/commands/hybrid.ts
12076
12500
  function launchOptionsFromEnv(env = process.env) {
12077
12501
  const minutes = (name) => Number(need(name, env));
12078
12502
  return {
@@ -12094,444 +12518,302 @@ function launchOptionsFromEnv(env = process.env) {
12094
12518
  securityGroup: DEFAULTS.securityGroup,
12095
12519
  sshKey: DEFAULTS.sshKey,
12096
12520
  onlineSeconds: Number(env.ONLINE_SECONDS || DEFAULTS.onlineSeconds),
12097
- tries: Number(env.LAUNCH_TRIES || DEFAULTS.tries),
12098
- keepMissed: env.KEEP_MISSED || void 0,
12099
- diagSshKey: env.DIAG_SSH_KEY || void 0
12521
+ tries: DEFAULTS.tries
12100
12522
  };
12101
12523
  }
12102
- function register3(program2) {
12103
- program2.command("launch").description("launch one throwaway runner for one job: JIT config, instance, wait until the runner is online").argument("<kind>", "build, test, dummy, or bare (the probe\u2019s user-data)").argument("<label>", "the one runner label the job asks for").argument("[app]", "the app, for the instance\u2019s labels").action(async (kind, label, app, _opts, command) => {
12524
+ function instanceLegs(matrix, tests = "[]") {
12525
+ const builds = JSON.parse(matrix);
12526
+ const testLegs = JSON.parse(tests);
12527
+ return [
12528
+ ...builds.map((l) => ({ kind: "build", label: l.runner, app: l.app })),
12529
+ ...testLegs.map((l) => ({ kind: "test", label: l.runner, app: l.app }))
12530
+ ];
12531
+ }
12532
+ function register2(program2) {
12533
+ const hybrid = program2.command("hybrid").description("the hybrid strategy\u2019s own: the throwaway instances its large jobs run on");
12534
+ hybrid.command("create-instances").description(
12535
+ "create one instance per build and test leg of the plan and wait for each runner; a miss is kept, stopped, mailed about and replaced once"
12536
+ ).argument("<matrix>", "the plan\u2019s build matrix, JSON").argument("[tests]", "the plan\u2019s test matrix, JSON").action(async (matrix, tests, _opts, command) => {
12104
12537
  const ctx = contextOf(command);
12105
12538
  const github = githubOf(ctx);
12106
12539
  if (!github) throw new Error("RUNNER_TOKEN is empty");
12107
- const result = await launch(ctx, exoOf(ctx), github, { ...launchOptionsFromEnv(), kind, label, app });
12108
- ctx.result(result, (r) => `${r.id} ${r.label} ${r.createdAt}`);
12109
- if (process.env.GITHUB_OUTPUT)
12110
- appendFileSync2(
12111
- process.env.GITHUB_OUTPUT,
12112
- `id=${result.id}
12113
- label=${result.label}
12114
- created=${result.createdAt}
12115
- `
12116
- );
12540
+ const legs = instanceLegs(matrix, tests);
12541
+ const result = await createInstances(ctx, exoOf(ctx), github, legs, launchOptionsFromEnv());
12542
+ ctx.result(result, (r) => r.map((l) => `${l.id} ${l.label} ${l.createdAt}`).join("\n"));
12543
+ });
12544
+ hybrid.command("destroy-instances").description(
12545
+ "delete every instance this run created, kept ones excepted, and the runner registrations it never used"
12546
+ ).action(async (_opts, command) => {
12547
+ const ctx = contextOf(command);
12548
+ const result = await destroyInstances(ctx, exoOf(ctx), githubOf(ctx), runIdentity());
12549
+ ctx.result(result, (r) => [...r.instances, ...r.registrations.map((n) => `registration ${n}`)].join("\n"));
12550
+ });
12551
+ hybrid.command("sweep-instances").description(
12552
+ "delete every instance that is stopped or past its deadline and mail about it; a kept one goes after fourteen days"
12553
+ ).action(async (_opts, command) => {
12554
+ const ctx = contextOf(command);
12555
+ const result = await sweepInstances(ctx, exoOf(ctx), { smtp: smtpFromEnv() });
12556
+ ctx.result(result, (r) => r.removed.map((x) => `${x.name} (${x.reason})`).join("\n"));
12117
12557
  });
12118
12558
  }
12119
12559
 
12120
12560
  // src/commands/plan.ts
12121
- import { appendFileSync as appendFileSync3 } from "node:fs";
12561
+ import { appendFileSync as appendFileSync2 } from "node:fs";
12122
12562
  import { resolve as resolve2 } from "node:path";
12123
-
12124
- // src/core/plan.ts
12125
- var NONEXISTENT = "<NONEXISTENT>";
12126
- function hashesOf(dryRun, scope) {
12127
- const data = JSON.parse(dryRun);
12128
- const tasks = (data.tasks ?? []).filter((t) => t.package.startsWith(`${scope}/`) && t.command !== NONEXISTENT);
12129
- const app = (t) => t.package.slice(scope.length + 1);
12130
- return tasks.filter((t) => t.taskId.endsWith("#build")).map((b) => {
12131
- const test2 = tasks.find((t) => t.package === b.package && t.taskId.endsWith("#test"));
12132
- return { app: app(b), hash: b.hash, ...test2 ? { test: test2.hash } : {} };
12133
- });
12134
- }
12135
- async function readHashes(ctx, dir, scope) {
12136
- const pkg = JSON.parse(await ctx.readFile(`${dir}/package.json`));
12137
- const turbo = pkg.devDependencies?.turbo ?? "latest";
12138
- const out = await ctx.read("npx", ["--yes", `turbo@${turbo}`, "run", "build", "test", "--dry-run=json"], {
12139
- cwd: dir
12140
- });
12141
- return hashesOf(out, scope);
12142
- }
12143
- function legsOf(apps, hashes, mode, runId, attempt) {
12144
- return apps.flatMap((app) => {
12145
- const hash = hashes.find((h) => h.app === app)?.hash;
12146
- if (!hash) throw new UsageError(`no build hash for ${app}; is it an app package of the workspace?`);
12147
- const copies = mode === "repro" ? 2 : 1;
12148
- return Array.from({ length: copies }, (_, i) => {
12149
- const n = i + 1;
12150
- const base = `exo-build-${runId}-${app}-${attempt}`;
12151
- return { app, hash, label: mode === "repro" ? `${base}-${n}` : base, n };
12563
+ function register3(program2) {
12564
+ withStrategy(program2.command("plan")).description(
12565
+ "compute the build and test hashes, look them up in the registry, and print the builds, tests and deploys the run needs"
12566
+ ).argument("[apps...]", "the apps (default: every directory under apps/)").option(
12567
+ "--mode <mode>",
12568
+ "build (skip what the registry holds), force (build all), repro (two no-cache builds per app)",
12569
+ "build"
12570
+ ).option("--test", "plan the tests too, for the apps with a test task").option("--deploy", "plan the deploys too: the environment and each app\u2019s target in it").option("--environment <name>", "the environment to release into (default: from the branch, main or develop)").option("--inventory <dir>", "the platform repo\u2019s inventory, for --deploy", "platform/inventory").option("-C, --dir <dir>", "the checkout to read", ".").action(async (apps, opts, command) => {
12571
+ const ctx = contextOf(command);
12572
+ const s = settings(opts);
12573
+ const dir = resolve2(opts.dir);
12574
+ const env = process.env;
12575
+ const all = apps.length > 0 ? apps : (await ctx.readDir(`${dir}/apps`)).filter((e) => e.directory).map((e) => e.name).sort();
12576
+ const environment = opts.deploy ? environmentOf(env.GITHUB_EVENT_NAME ?? "", env.GITHUB_REF_NAME ?? "", opts.environment) : "";
12577
+ const result = await plan(ctx, {
12578
+ apps: all,
12579
+ mode: opts.mode,
12580
+ tests: opts.test ?? false,
12581
+ hashes: await readHashes(ctx, dir, all),
12582
+ strategy: s.strategy,
12583
+ registry: registryFor(s),
12584
+ // Only hybrid names anything after the run: its instances.
12585
+ run: s.strategy === "hybrid" ? { runId: need("GITHUB_RUN_ID"), attempt: env.GITHUB_RUN_ATTEMPT || "1" } : { runId: env.GITHUB_RUN_ID || "0", attempt: env.GITHUB_RUN_ATTEMPT || "1" },
12586
+ deploy: environment ? {
12587
+ environment,
12588
+ targets: targetsIn(await readDeployTargets(ctx, resolve2(opts.inventory)), environment),
12589
+ prefix: imagePrefix(),
12590
+ sha: need("GITHUB_SHA")
12591
+ } : void 0
12152
12592
  });
12593
+ ctx.result(
12594
+ result,
12595
+ (r) => [
12596
+ ...r.legs.map((l) => `build ${l.app} ${l.hash} ${l.runner}`),
12597
+ ...r.tests.map((l) => `test ${l.app} ${l.hash} ${l.runner}`),
12598
+ ...r.deploys.map((l) => `deploy ${l.app} ${l.host} ${l.image}`)
12599
+ ].join("\n")
12600
+ );
12601
+ if (env.GITHUB_OUTPUT)
12602
+ appendFileSync2(
12603
+ env.GITHUB_OUTPUT,
12604
+ [
12605
+ `matrix=${JSON.stringify(result.legs)}`,
12606
+ `tests=${JSON.stringify(result.tests)}`,
12607
+ `apps=${JSON.stringify(result.apps)}`,
12608
+ `deploys=${JSON.stringify(result.deploys)}`,
12609
+ `environment=${result.environment}`,
12610
+ ""
12611
+ ].join("\n")
12612
+ );
12153
12613
  });
12154
12614
  }
12155
- function testLegsOf(apps, hashes, runId, attempt) {
12156
- return apps.flatMap((app) => {
12157
- const h = hashes.find((x) => x.app === app);
12158
- if (!h?.test) return [];
12159
- return [{ app, hash: h.test, build: h.hash, label: `exo-test-${runId}-${app}-${attempt}` }];
12160
- });
12161
- }
12162
- async function plan(ctx, exo, github, options) {
12163
- const { runId, attempt } = options.launch.run;
12164
- const result = {
12165
- legs: [],
12166
- tests: [],
12167
- apps: options.apps.map((app) => {
12168
- const h = options.hashes.find((x) => x.app === app);
12169
- if (!h) throw new UsageError(`no build hash for ${app}; is it an app package of the workspace?`);
12170
- return h;
12171
- }),
12172
- skipped: [],
12173
- skippedTests: []
12174
- };
12175
- const builds = [];
12176
- for (const leg of legsOf(options.apps, options.hashes, options.mode, runId, attempt)) {
12177
- if (options.mode === "build") {
12178
- const port = await tagExists(ctx, leg.app, `build-${leg.hash}`);
12179
- if (port !== void 0) {
12180
- ctx.say(`${leg.app}: build-${leg.hash} exists in the registry on :${port}, nothing to build`);
12181
- result.skipped.push({ app: leg.app, hash: leg.hash, port });
12182
- continue;
12183
- }
12184
- }
12185
- builds.push(leg);
12186
- }
12187
- const tests = [];
12188
- if (options.tests && options.mode !== "repro") {
12189
- for (const leg of testLegsOf(options.apps, options.hashes, runId, attempt)) {
12190
- if (options.mode === "build") {
12191
- const port = await tagExists(ctx, leg.app, `test-${leg.hash}`);
12192
- if (port !== void 0) {
12193
- ctx.say(`${leg.app}: test-${leg.hash} exists in the registry on :${port}, nothing to test`);
12194
- result.skippedTests.push({ app: leg.app, hash: leg.hash, port });
12195
- continue;
12196
- }
12197
- }
12198
- tests.push(leg);
12199
- }
12200
- }
12201
- const launches = await Promise.allSettled([
12202
- ...builds.map(
12203
- (leg) => launch(ctx, exo, github, { ...options.launch, kind: "build", label: leg.label, app: leg.app })
12204
- ),
12205
- ...tests.map(
12206
- (leg) => launch(ctx, exo, github, { ...options.launch, kind: "test", label: leg.label, app: leg.app })
12207
- )
12208
- ]);
12209
- const failed = launches.filter((l) => l.status === "rejected");
12210
- for (const l of launches) if (l.status === "fulfilled" && l.value.kept) result.kept = l.value.kept;
12211
- if (failed.length > 0) {
12212
- for (const f of failed) ctx.say(`\u2717 ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`);
12213
- throw new Error(`${failed.length} launch(es) failed`);
12214
- }
12215
- result.legs = builds;
12216
- result.tests = tests;
12217
- return result;
12218
- }
12219
- async function report(ctx, legs, mode, runId) {
12220
- const apps = [...new Set(legs.map((l) => l.app))].sort();
12221
- const out = [];
12222
- for (const app of apps) {
12223
- const hash = legs.find((l) => l.app === app)?.hash ?? "";
12224
- const c = { app, hash, digest: await digest(ctx, app, `build-${hash}`) };
12225
- ctx.say(`${app} build-${hash}: ${c.digest ?? "missing"}`);
12226
- if (mode === "repro") {
12227
- const [one, two] = await Promise.all([
12228
- digest(ctx, app, `repro-${runId}-1`),
12229
- digest(ctx, app, `repro-${runId}-2`)
12230
- ]);
12231
- const identical = one !== void 0 && one === two;
12232
- const [l1, l2] = identical ? [[], []] : await Promise.all([layers(ctx, app, `repro-${runId}-1`), layers(ctx, app, `repro-${runId}-2`)]);
12233
- const differing = [];
12234
- const a = l1 ?? [];
12235
- const b = l2 ?? [];
12236
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
12237
- if (a[i]?.digest !== b[i]?.digest)
12238
- differing.push(
12239
- `layer ${i + 1}: ${a[i]?.digest ?? "-"} (${a[i]?.size ?? "-"}) vs ${b[i]?.digest ?? "-"} (${b[i]?.size ?? "-"})`
12240
- );
12241
- }
12242
- c.repro = { one, two, identical, layers: a.length, same: a.length - differing.length, differing };
12243
- if (identical) ctx.say(` reproducible: both builds are ${one}`);
12244
- else {
12245
- ctx.say(
12246
- ` not identical: ${one ?? "-"} vs ${two ?? "-"}; layers: ${a.length} in each, ${a.length - differing.length} identical`
12247
- );
12248
- for (const d of differing) ctx.say(` ${d}`);
12249
- }
12250
- }
12251
- out.push(c);
12252
- }
12253
- return out;
12615
+
12616
+ // src/commands/publish.ts
12617
+ import { appendFileSync as appendFileSync3 } from "node:fs";
12618
+
12619
+ // src/core/publish.ts
12620
+ async function publish(ctx, options) {
12621
+ const { app, build: build2, test: test2, sha, registry } = options;
12622
+ const image = imageName(options.prefix, app);
12623
+ const target = `${image}:build-${build2}`;
12624
+ const source = test2 ? `test-${test2}` : `build-${build2}`;
12625
+ const missing = `${app}:${source} is in no registry here; ${test2 ? "the tests did not pass on this build" : "the build never ran"}`;
12626
+ if (registry.kind === "ghcr") {
12627
+ const ref = `${image}:${source}`;
12628
+ if (await craneDigest(ctx, ref) === void 0 && !ctx.dryRun) throw new Error(missing);
12629
+ ctx.say(`publish: ${ref} as ${sha}`);
12630
+ await ctx.apply([run("crane", ["tag", ref, sha])]);
12631
+ return { app, image, copied: false, tags: [`build-${build2}`, ...test2 ? [`test-${test2}`] : [], sha] };
12632
+ }
12633
+ const copied = await craneDigest(ctx, target) === void 0;
12634
+ if (copied) {
12635
+ const from = await findTag(ctx, registry, app, source);
12636
+ if (from === void 0 && !ctx.dryRun) throw new Error(missing);
12637
+ ctx.say(`publish: ${app}:${source} -> ${target}`);
12638
+ await ctx.apply([run("crane", ["copy", "--insecure", from ?? `localhost:5000/${app}:${source}`, target])]);
12639
+ } else ctx.say(`publish: ${target} is there already, tagging only`);
12640
+ const tags = [sha, ...test2 ? [`test-${test2}`] : []];
12641
+ await ctx.apply(tags.map((t) => run("crane", ["tag", target, t])));
12642
+ return { app, image, copied, tags: [`build-${build2}`, ...tags] };
12254
12643
  }
12255
12644
 
12256
- // src/commands/plan.ts
12645
+ // src/commands/publish.ts
12257
12646
  function register4(program2) {
12258
- program2.command("plan").description(
12259
- "compute the build and test hashes, look them up in the registry, and launch one runner per image to build or test"
12260
- ).argument("[apps...]", "the apps (default: every directory under apps/)").requiredOption(
12261
- "--mode <mode>",
12262
- "build (skip what the registry holds), force (build all), repro (two no-cache builds per app)"
12263
- ).option("--tests", "plan test runners too, for the apps with a test task").option("--scope <scope>", "the npm scope of the workspace packages", "@damage-ms").option("-C, --dir <dir>", "the checkout to read", ".").action(
12264
- async (apps, opts, command) => {
12647
+ withStrategy(program2.command("publish")).description("publish one app\u2019s tested image as <sha> in the forge\u2019s registry, beside build-<hash> and test-<hash>").argument("<app>", "the app").argument("<build>", "Turbo\u2019s hash of the app\u2019s build task").argument("<sha>", "the commit, the tag a deploy takes").option("--test <hash>", "Turbo\u2019s hash of the app\u2019s test task; the image is read from test-<hash>").action(
12648
+ async (app, build2, sha, opts, command) => {
12265
12649
  const ctx = contextOf(command);
12266
- const dir = resolve2(opts.dir);
12267
- const github = githubOf(ctx);
12268
- if (!github) throw new Error("RUNNER_TOKEN is empty");
12269
- const all = apps.length > 0 ? apps : (await ctx.readDir(`${dir}/apps`)).filter((e) => e.directory).map((e) => e.name).sort();
12270
- const result = await plan(ctx, exoOf(ctx), github, {
12271
- apps: all,
12272
- mode: opts.mode,
12273
- tests: opts.tests ?? false,
12274
- hashes: await readHashes(ctx, dir, opts.scope),
12275
- launch: launchOptionsFromEnv()
12650
+ const result = await publish(ctx, {
12651
+ app,
12652
+ build: build2,
12653
+ test: opts.test || void 0,
12654
+ sha,
12655
+ prefix: imagePrefix(),
12656
+ registry: registryFor(settings(opts))
12276
12657
  });
12277
- ctx.result(
12278
- result,
12279
- (r) => [
12280
- ...r.legs.map((l) => `build ${l.app} ${l.hash} ${l.label}`),
12281
- ...r.tests.map((l) => `test ${l.app} ${l.hash} ${l.label}`)
12282
- ].join("\n")
12283
- );
12284
- if (process.env.GITHUB_OUTPUT)
12285
- appendFileSync3(
12286
- process.env.GITHUB_OUTPUT,
12287
- `matrix=${JSON.stringify(result.legs)}
12288
- tests=${JSON.stringify(result.tests)}
12289
- apps=${JSON.stringify(result.apps)}
12290
- kept=${result.kept ?? ""}
12291
- `
12292
- );
12658
+ ctx.result(result, (r) => `${r.image}:${sha}${r.copied ? "" : " (was there)"}`);
12659
+ if (process.env.GITHUB_OUTPUT) appendFileSync3(process.env.GITHUB_OUTPUT, `image=${result.image}:${sha}
12660
+ `);
12293
12661
  }
12294
12662
  );
12295
12663
  }
12296
12664
 
12297
- // src/core/probe.ts
12298
- async function probe(ctx, exo, options) {
12299
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
12300
- const [templateId, instanceTypeId, securityGroupId] = await Promise.all([
12301
- exo.templateId(options.template),
12302
- exo.instanceTypeId(options.instanceType),
12303
- exo.securityGroupId(DEFAULTS.securityGroup)
12304
- ]);
12305
- const text = render2(BARE_USER_DATA, { POWEROFF_MINUTES: "60" });
12306
- const userData = options.plain ? Buffer.from(text).toString("base64") : encodeUserData(text);
12307
- const boots = [];
12308
- const started = now();
12309
- for (let i = 1; i <= options.boots; i++) {
12310
- const name = `probe-${iso(now()).slice(11, 19).replaceAll(":", "")}-${i}`;
12311
- const request = {
12312
- name,
12313
- templateId,
12314
- instanceTypeId,
12315
- securityGroupId,
12316
- sshKey: DEFAULTS.sshKey,
12317
- diskSize: 50,
12318
- userDataBase64: userData,
12319
- labels: { "plexus-ci": "true", kind: "probe", "death-deadline": deadline(now(), 90) }
12320
- };
12321
- const op = await exo.createInstance(request);
12322
- boots.push({ name, id: op?.reference, result: op ? "pending" : "dry run", ok: false });
12323
- }
12324
- if (ctx.dryRun) return boots;
12325
- const pending = () => boots.filter((b) => b.result === "pending");
12326
- while (pending().length > 0 && (now().getTime() - started.getTime()) / 1e3 < options.waitSeconds) {
12327
- const instances = await exo.listInstances();
12328
- for (const b of pending()) {
12329
- const i = instances.find((x) => x.id === b.id);
12330
- if (!i?.publicIp) continue;
12331
- b.ip = i.publicIp;
12332
- const ok2 = await ctx.read("ssh", sshArgs(options.sshKey, i.publicIp, "true")).then(
12333
- () => true,
12334
- () => false
12335
- );
12336
- if (ok2) {
12337
- b.result = `ok ${Math.round((now().getTime() - started.getTime()) / 1e3)}s`;
12338
- b.ok = true;
12339
- }
12665
+ // src/commands/registry.ts
12666
+ import { resolve as resolve3 } from "node:path";
12667
+
12668
+ // src/core/ghcr.ts
12669
+ var PRUNE_AFTER_DAYS = 30;
12670
+ var CANDIDATE_TAG = /^(build|test)-[0-9a-f]+$/;
12671
+ function prunable(versions, now, days = PRUNE_AFTER_DAYS) {
12672
+ const before = now.getTime() - days * 864e5;
12673
+ return versions.filter(
12674
+ (v) => v.tags.length > 0 && v.tags.every((t) => CANDIDATE_TAG.test(t)) && Date.parse(v.createdAt) < before
12675
+ );
12676
+ }
12677
+ var Packages = class {
12678
+ ctx;
12679
+ org;
12680
+ token;
12681
+ constructor(ctx, org, token) {
12682
+ this.ctx = ctx;
12683
+ this.org = org;
12684
+ this.token = token;
12685
+ }
12686
+ config(method, path2) {
12687
+ return `${[
12688
+ `url = "https://api.github.com/orgs/${this.org}/packages${path2}"`,
12689
+ `request = "${method}"`,
12690
+ "fail-with-body",
12691
+ "silent",
12692
+ "show-error",
12693
+ `header = "Authorization: Bearer ${this.token}"`,
12694
+ 'header = "Accept: application/vnd.github+json"',
12695
+ 'header = "X-GitHub-Api-Version: 2022-11-28"'
12696
+ ].join("\n")}
12697
+ `;
12698
+ }
12699
+ /** Every page of a list, a hundred entries each. */
12700
+ async pages(path2) {
12701
+ const all = [];
12702
+ for (let page = 1; ; page++) {
12703
+ const sep = path2.includes("?") ? "&" : "?";
12704
+ const out = await this.ctx.read("curl", ["-K", "-"], {
12705
+ input: this.config("GET", `${path2}${sep}per_page=100&page=${page}`)
12706
+ });
12707
+ const items = JSON.parse(out);
12708
+ all.push(...items);
12709
+ if (items.length < 100) return all;
12340
12710
  }
12341
- if (pending().length > 0) await sleep(1e4);
12342
12711
  }
12343
- for (const b of boots) {
12344
- if (b.result === "pending") b.result = b.ip ? "unreachable" : "no ip";
12345
- ctx.say(
12346
- `${options.template} ${options.instanceType} boot ${b.name} (${b.id ?? "no id"}, ${b.ip ?? "no ip"}): ${b.result}`
12712
+ async versions(name) {
12713
+ const raw = await this.pages(`/container/${encodeURIComponent(name)}/versions`);
12714
+ return raw.map((v) => ({ id: v.id, createdAt: v.created_at, tags: v.metadata?.container?.tags ?? [] }));
12715
+ }
12716
+ async deleteVersion(name, id) {
12717
+ await this.ctx.effect(
12718
+ run("curl", ["-K", "-"], {
12719
+ input: this.config("DELETE", `/container/${encodeURIComponent(name)}/versions/${id}`)
12720
+ })
12347
12721
  );
12348
- if (b.id && (b.ok || !options.keep)) await exo.deleteInstance(b.id);
12349
- else if (b.id) ctx.say(` kept ${b.name} for inspection`);
12350
12722
  }
12351
- const ok = boots.filter((b) => b.ok).length;
12723
+ };
12724
+ function packageOf(prefix, app) {
12725
+ const path2 = prefix.toLowerCase().replace(/\/$/, "").split("/").slice(2);
12726
+ return [...path2, app].join("/");
12727
+ }
12728
+ async function prune(ctx, packages, names, now = /* @__PURE__ */ new Date()) {
12729
+ const pruned = [];
12730
+ for (const name of names) {
12731
+ let versions;
12732
+ try {
12733
+ versions = await packages.versions(name);
12734
+ } catch (error) {
12735
+ if (!(error instanceof ExecError)) throw error;
12736
+ ctx.say(`rotate: no package ${name} in ghcr.io/${packages.org}`);
12737
+ continue;
12738
+ }
12739
+ for (const v of prunable(versions, now)) {
12740
+ ctx.say(`rotate: ${name} ${v.tags.join(" ")} (${v.createdAt}) is older than ${PRUNE_AFTER_DAYS} days`);
12741
+ await packages.deleteVersion(name, v.id);
12742
+ pruned.push({ package: name, id: v.id, tags: v.tags });
12743
+ }
12744
+ }
12352
12745
  ctx.say(
12353
- `== ${options.template} on ${options.instanceType}: ${ok} ok, ${boots.length - ok} failed of ${boots.length}`
12746
+ `rotate: ${pruned.length} candidate version(s) pruned from ${names.length} package(s) of ghcr.io/${packages.org}`
12354
12747
  );
12355
- return boots;
12356
- }
12357
-
12358
- // src/commands/probe.ts
12359
- function register5(program2) {
12360
- program2.command("probe").description("boot bare instances and judge each by an SSH login; a failed one is kept with --keep").option("--boots <n>", "instances to boot at once", "10").option("--template <name>", "the template", DEFAULTS.template).option("--type <type>", "the instance type", DEFAULTS.instanceType).option("--wait <seconds>", "seconds to wait for the verdicts", "180").option("--ssh-key <path>", "the private half of the key the instances get", `${process.env.HOME}/.ssh/deploy`).option("--keep", "keep a failed instance for inspection").option("--plain", "send the user-data as plain base64 instead of gzip").action(async (opts, command) => {
12361
- const ctx = contextOf(command);
12362
- const boots = await probe(ctx, exoOf(ctx), {
12363
- template: opts.template,
12364
- instanceType: opts.type,
12365
- zone: zoneOf(),
12366
- boots: Number(opts.boots),
12367
- waitSeconds: Number(opts.wait),
12368
- sshKey: opts.sshKey,
12369
- keep: opts.keep,
12370
- plain: opts.plain
12371
- });
12372
- ctx.result(boots, (b) => b.map((x) => `${x.name}: ${x.result}`).join("\n"));
12373
- if (boots.some((b) => !b.ok)) process.exitCode = 1;
12374
- });
12748
+ return pruned;
12375
12749
  }
12376
12750
 
12377
12751
  // src/commands/registry.ts
12378
- function register6(program2) {
12379
- const registry = program2.command("registry").description(
12380
- "start the registry sidecars: :5000 on this month\u2019s bucket read-write, :5001 on the previous month\u2019s read-only"
12381
- ).action(async (_opts, command) => {
12752
+ function register5(program2) {
12753
+ const registry = withStrategy(program2.command("registry")).description(
12754
+ "make the candidates\u2019 registry reachable here: the pair on localhost (started unless :5000 answers), or a login check for the forge\u2019s"
12755
+ ).action(async (opts, command) => {
12382
12756
  const ctx = contextOf(command);
12383
- const storage = storageFromEnv();
12384
- if (!storage)
12385
- throw new Error("REGISTRY_ENDPOINT, REGISTRY_REGION, REGISTRY_ACCESS_KEY and REGISTRY_SECRET_KEY are needed");
12386
- const result = await startSidecars(ctx, storage, ownerOf(need("GITHUB_REPOSITORY")), zoneOf());
12757
+ const s = settings(opts);
12758
+ if (s.registry === "ghcr") {
12759
+ const host = imagePrefix().split("/")[0] ?? "ghcr.io";
12760
+ if (!await loggedIn(ctx, host) && !ctx.dryRun)
12761
+ throw new Error(`no login for ${host} here; the job logs in before it asks for the registry`);
12762
+ ctx.result(`registry: ${imagePrefix()}, logged in`);
12763
+ return;
12764
+ }
12765
+ const result = await startSidecars(ctx, storageFromEnv(), ownerOf(need("GITHUB_REPOSITORY")));
12387
12766
  ctx.result(
12388
12767
  result,
12389
12768
  (r) => `:5000 ${r.current}
12390
12769
  :5001 ${r.previous}${r.previousAnswers ? "" : " (not answering)"}`
12391
12770
  );
12392
12771
  });
12393
- registry.command("exists").description("exit 0 when either sidecar holds the tag").argument("<app>").argument("<tag>").action(async (app, tag, _opts, command) => {
12772
+ withStrategy(registry.command("exists")).description("exit 0 when the registry holds the tag").argument("<app>").argument("<tag>").action(async (app, tag, opts, command) => {
12394
12773
  const ctx = contextOf(command);
12395
- const port = await tagExists(ctx, app, tag);
12396
- if (port === void 0) {
12774
+ const ref = await findTag(ctx, registryFor(settings(opts)), app, tag);
12775
+ if (ref === void 0) {
12397
12776
  ctx.say(`${app}:${tag} is in no registry here`);
12398
12777
  process.exitCode = 1;
12399
- } else ctx.result(`${app}:${tag} in the registry on :${port}`);
12778
+ } else ctx.result(ref);
12400
12779
  });
12401
- }
12402
-
12403
- // src/commands/release.ts
12404
- import { appendFileSync as appendFileSync4 } from "node:fs";
12405
-
12406
- // src/core/release.ts
12407
- var imageName = (prefix, app) => `${prefix.toLowerCase().replace(/\/$/, "")}/${app}`;
12408
- async function exists(ctx, ref) {
12409
- try {
12410
- await ctx.read("crane", ["digest", ref]);
12411
- return true;
12412
- } catch {
12413
- return false;
12414
- }
12415
- }
12416
- async function release(ctx, options) {
12417
- const { app, build: build2, test: test2, sha } = options;
12418
- const image = imageName(options.prefix, app);
12419
- const target = `${image}:build-${build2}`;
12420
- const copied = !await exists(ctx, target);
12421
- if (copied) {
12422
- const source = test2 ? `test-${test2}` : `build-${build2}`;
12423
- const port = await tagExists(ctx, app, source);
12424
- if (port === void 0 && !ctx.dryRun)
12425
- throw new Error(
12426
- `${app}:${source} is in no registry here; ${test2 ? "the tests did not pass on this build" : "the build never ran"}`
12427
- );
12428
- ctx.say(`release: ${app}:${source} -> ${target}`);
12429
- await ctx.apply([run("crane", ["copy", "--insecure", `localhost:${port ?? 5e3}/${app}:${source}`, target])]);
12430
- } else ctx.say(`release: ${target} is there already, tagging only`);
12431
- const tags = [sha, ...test2 ? [`test-${test2}`] : []];
12432
- await ctx.apply(tags.map((t) => run("crane", ["tag", target, t])));
12433
- return { app, image, copied, tags: [`build-${build2}`, ...tags] };
12434
- }
12435
-
12436
- // src/commands/release.ts
12437
- function register7(program2) {
12438
- program2.command("release").description("copy one app\u2019s tested image from the sidecar to GHCR as build-<hash>, test-<hash> and <sha>").argument("<app>", "the app").argument("<build>", "Turbo\u2019s hash of the app\u2019s build task").argument("<sha>", "the commit, a tag on the released image").option("--test <hash>", "Turbo\u2019s hash of the app\u2019s test task; the image is read from test-<hash>").action(async (app, build2, sha, opts, command) => {
12439
- const ctx = contextOf(command);
12440
- const prefix = process.env.IMAGE_PREFIX || `ghcr.io/${ownerOf(need("GITHUB_REPOSITORY"))}`;
12441
- const result = await release(ctx, { app, build: build2, test: opts.test || void 0, sha, prefix });
12442
- ctx.result(result, (r) => `${r.image}:${sha}${r.copied ? "" : " (was there)"}`);
12443
- if (process.env.GITHUB_OUTPUT) appendFileSync4(process.env.GITHUB_OUTPUT, `image=${result.image}:${sha}
12444
- `);
12445
- });
12446
- }
12447
-
12448
- // src/commands/report.ts
12449
- function register8(program2) {
12450
- program2.command("report").description("the digests the registry holds for a run\u2019s matrix, and in repro mode the layer-by-layer comparison").argument("<matrix>", "the plan\u2019s matrix, JSON").requiredOption("--mode <mode>", "build, force or repro").action(async (matrix, opts, command) => {
12780
+ withStrategy(registry.command("rotate")).description(
12781
+ "the retention: this month\u2019s bucket exists and the one from two months ago goes; in the forge\u2019s registry, candidates older than thirty days go"
12782
+ ).argument("[apps...]", "under ghcr: the apps whose packages are pruned (default: every directory under apps/)").option("-C, --dir <dir>", "the checkout to read", ".").action(async (apps, opts, command) => {
12451
12783
  const ctx = contextOf(command);
12452
- const legs = JSON.parse(matrix);
12453
- const result = await report(ctx, legs, opts.mode, need("GITHUB_RUN_ID"));
12454
- ctx.result(result, () => "");
12455
- });
12456
- }
12457
-
12458
- // src/core/sweep.ts
12459
- function reasonToRemove(instance, now) {
12460
- const reasons = [];
12461
- if (instance.state === "stopped") reasons.push("stopped");
12462
- const deadline2 = instance.labels["death-deadline"];
12463
- if (deadline2 && deadline2 < now) reasons.push(`past its deadline ${deadline2}`);
12464
- return reasons.length > 0 ? reasons.join(", ") : void 0;
12465
- }
12466
- async function sweep(ctx, exo, options = {}) {
12467
- const now = options.now ?? /* @__PURE__ */ new Date();
12468
- const result = { removed: [], kept: [] };
12469
- for (const i of await exo.listInstances()) {
12470
- if (i.labels["plexus-ci"] !== "true") continue;
12471
- const reason = reasonToRemove(i, iso(now));
12472
- if (reason === void 0) {
12473
- ctx.say(`${i.name} (${i.id}): ${i.state}, until ${i.labels["death-deadline"] ?? "-"}, kept`);
12474
- result.kept.push(i.name);
12475
- continue;
12476
- }
12477
- ctx.say(`${i.name} (${i.id}): ${reason}`);
12478
- await exo.deleteInstance(i.id);
12479
- result.removed.push({ name: i.name, reason });
12480
- }
12481
- if (result.removed.length > 0 && options.smtp) {
12482
- const text = `The daily sweep removed ${result.removed.length} plexus-ci instance(s) in ${exo.zone} that the other layers left behind:
12483
-
12484
- ${result.removed.map((r) => `${r.name} (${r.reason})`).join("\n")}
12485
- `;
12486
- await sendMail(ctx, options.smtp, "plexus-ci: the sweep removed instances", text, now);
12487
- }
12488
- ctx.say(`sweep: ${result.removed.length} removed`);
12489
- if (options.storage && options.owner) {
12490
- const { expired } = registryBuckets(options.owner, now);
12491
- const sos = new Storage(ctx, options.storage.accessKey, options.storage.secretKey);
12492
- if (await sos.bucketExists(expired)) {
12493
- ctx.say(`sweep: the registry bucket ${expired} is two months old`);
12494
- await sos.removeBucket(expired);
12495
- result.bucket = expired;
12784
+ const s = settings(opts);
12785
+ const owner = ownerOf(need("GITHUB_REPOSITORY"));
12786
+ if (s.registry === "ghcr") {
12787
+ const all = apps.length > 0 ? apps : (await ctx.readDir(`${resolve3(opts.dir)}/apps`)).filter((e) => e.directory).map((e) => e.name).sort();
12788
+ const names = all.map((app) => packageOf(imagePrefix(), app));
12789
+ const result2 = await prune(ctx, new Packages(ctx, owner, need("GITHUB_TOKEN")), names);
12790
+ ctx.result(result2, (r) => r.map((v) => `${v.package} ${v.tags.join(" ")}`).join("\n"));
12791
+ return;
12496
12792
  }
12497
- }
12498
- return result;
12499
- }
12500
-
12501
- // src/commands/sweep.ts
12502
- function register9(program2) {
12503
- program2.command("sweep").description(
12504
- "delete every plexus-ci instance that is stopped or past its deadline, mail about it, rotate the registry buckets"
12505
- ).action(async (_opts, command) => {
12506
- const ctx = contextOf(command);
12507
- const repo = process.env.GITHUB_REPOSITORY;
12508
- const result = await sweep(ctx, exoOf(ctx), {
12509
- smtp: smtpFromEnv(),
12510
- storage: storageFromEnv(),
12511
- owner: repo ? ownerOf(repo) : void 0
12512
- });
12793
+ const storage = storageFromEnv();
12794
+ if (!storage)
12795
+ throw new Error("REGISTRY_ENDPOINT, REGISTRY_REGION, REGISTRY_ACCESS_KEY and REGISTRY_SECRET_KEY are needed");
12796
+ const result = await rotateBuckets(ctx, storage, owner);
12513
12797
  ctx.result(
12514
12798
  result,
12515
- (r) => [...r.removed.map((x) => `${x.name} (${x.reason})`), ...r.bucket ? [`bucket ${r.bucket}`] : []].join("\n")
12799
+ (r) => [...r.created ? [`created ${r.created}`] : [], ...r.removed ? [`removed ${r.removed}`] : []].join("\n")
12516
12800
  );
12517
12801
  });
12518
12802
  }
12519
12803
 
12520
- // src/commands/teardown.ts
12521
- function register10(program2) {
12522
- program2.command("teardown").description(
12523
- "delete every instance this run launched, kept ones excepted, and the runner registrations it never used"
12524
- ).action(async (_opts, command) => {
12804
+ // src/commands/report.ts
12805
+ function register6(program2) {
12806
+ withStrategy(program2.command("report")).description("the digests the registry holds for a run\u2019s matrix, and in repro mode the layer-by-layer comparison").argument("<matrix>", "the plan\u2019s matrix, JSON").requiredOption("--mode <mode>", "build, force or repro").action(async (matrix, opts, command) => {
12525
12807
  const ctx = contextOf(command);
12526
- const keep2 = (process.env.KEEP_IDS ?? "").split(/\s+/).filter(Boolean);
12527
- const result = await teardown(ctx, exoOf(ctx), githubOf(ctx), runIdentity(), keep2);
12528
- ctx.result(result, (r) => [...r.instances, ...r.registrations.map((n) => `registration ${n}`)].join("\n"));
12808
+ const legs = JSON.parse(matrix);
12809
+ const result = await report(ctx, registryFor(settings(opts)), legs, opts.mode, need("GITHUB_RUN_ID"));
12810
+ ctx.result(result, () => "");
12529
12811
  });
12530
12812
  }
12531
12813
 
12532
12814
  // src/commands/test.ts
12533
- import { appendFileSync as appendFileSync5 } from "node:fs";
12534
- import { resolve as resolve3 } from "node:path";
12815
+ import { appendFileSync as appendFileSync4 } from "node:fs";
12816
+ import { resolve as resolve4 } from "node:path";
12535
12817
 
12536
12818
  // src/core/test.ts
12537
12819
  import { join as join4 } from "node:path";
@@ -12557,16 +12839,15 @@ function specEnv(port, fromOverride) {
12557
12839
  };
12558
12840
  }
12559
12841
  async function test(ctx, options) {
12560
- const { app, hash, build: build2 } = options;
12842
+ const { app, hash, build: build2, registry } = options;
12561
12843
  const dir = options.dir ?? ".";
12562
12844
  const port = options.port ?? 3e3;
12563
12845
  const appDir = join4(dir, "apps", app);
12564
12846
  const composeDir = join4(appDir, "docker");
12565
12847
  const project = `ci-${app}`;
12566
- const registryPort = await tagExists(ctx, app, `build-${build2}`) ?? (ctx.dryRun ? CURRENT_PORT : void 0);
12567
- if (registryPort === void 0)
12848
+ const image = await findTag(ctx, registry, app, `build-${build2}`) ?? (ctx.dryRun ? candidateRef(registry, app, `build-${build2}`) : void 0);
12849
+ if (image === void 0)
12568
12850
  throw new Error(`${app}:build-${build2} is in no registry here; the build failed or never ran`);
12569
- const image = `localhost:${registryPort}/${app}:build-${build2}`;
12570
12851
  const tag = `test-${hash}`;
12571
12852
  const fromOverride = overrideEnv(await ctx.readFile(join4(composeDir, OVERRIDE)));
12572
12853
  const composeEnv = { PLEXUS_DEPLOYMENT_IMAGE: image, PLEXUS_LOOPBACK_PORT: String(port) };
@@ -12601,48 +12882,39 @@ ${logs}`);
12601
12882
  await ctx.apply([compose(["--profile", "migrate", "down", "--volumes", "--remove-orphans", "--timeout", "10"])]);
12602
12883
  }
12603
12884
  await ctx.apply([
12604
- run("docker", ["buildx", "imagetools", "create", "--tag", `localhost:${CURRENT_PORT}/${app}:${tag}`, image])
12885
+ run("docker", ["buildx", "imagetools", "create", "--tag", candidateRef(registry, app, tag), image])
12605
12886
  ]);
12606
12887
  const result = { app, hash, image, tag, stackSeconds, testSeconds };
12607
12888
  if (!ctx.dryRun) {
12608
- result.digest = await digest(ctx, app, tag);
12889
+ result.digest = await digestOf(ctx, registry, app, tag);
12609
12890
  ctx.say(`test: ${app} ${tag} -> ${result.digest ?? "<no digest>"}`);
12610
12891
  }
12611
12892
  return result;
12612
12893
  }
12613
12894
 
12614
12895
  // src/commands/test.ts
12615
- function register11(program2) {
12616
- program2.command("test").description("run one app\u2019s tests against its compose stack on the candidate image, and tag the image tested").argument("<app>", "the app, apps/<app> in the checkout").argument("<build>", "Turbo\u2019s hash of the app\u2019s build task, the candidate\u2019s tag").argument("<hash>", "Turbo\u2019s hash of the app\u2019s test task, the tag on success").option("-C, --dir <dir>", "the checkout to read", ".").action(async (app, build2, hash, opts, command) => {
12617
- const ctx = contextOf(command);
12618
- const result = await test(ctx, {
12619
- app,
12620
- build: build2,
12621
- hash,
12622
- dir: resolve3(opts.dir),
12623
- port: process.env.PLEXUS_LOOPBACK_PORT ? Number(process.env.PLEXUS_LOOPBACK_PORT) : void 0
12624
- });
12625
- ctx.result(result, (r) => r.digest ?? "");
12626
- if (process.env.GITHUB_OUTPUT && result.digest)
12627
- appendFileSync5(process.env.GITHUB_OUTPUT, `digest=${result.digest}
12896
+ function register7(program2) {
12897
+ withStrategy(program2.command("test")).description("run one app\u2019s tests against its compose stack on the candidate image, and tag the image tested").argument("<app>", "the app, apps/<app> in the checkout").argument("<build>", "Turbo\u2019s hash of the app\u2019s build task, the candidate\u2019s tag").argument("<hash>", "Turbo\u2019s hash of the app\u2019s test task, the tag on success").option("-C, --dir <dir>", "the checkout to read", ".").action(
12898
+ async (app, build2, hash, opts, command) => {
12899
+ const ctx = contextOf(command);
12900
+ const result = await test(ctx, {
12901
+ app,
12902
+ build: build2,
12903
+ hash,
12904
+ dir: resolve4(opts.dir),
12905
+ registry: registryFor(settings(opts)),
12906
+ port: process.env.PLEXUS_LOOPBACK_PORT ? Number(process.env.PLEXUS_LOOPBACK_PORT) : void 0
12907
+ });
12908
+ ctx.result(result, (r) => r.digest ?? "");
12909
+ if (process.env.GITHUB_OUTPUT && result.digest)
12910
+ appendFileSync4(process.env.GITHUB_OUTPUT, `digest=${result.digest}
12628
12911
  `);
12629
- });
12912
+ }
12913
+ );
12630
12914
  }
12631
12915
 
12632
12916
  // src/commands/index.ts
12633
- var commands = [
12634
- register4,
12635
- register3,
12636
- register,
12637
- register11,
12638
- register7,
12639
- register8,
12640
- register10,
12641
- register9,
12642
- register6,
12643
- register2,
12644
- register5
12645
- ];
12917
+ var commands = [register5, register3, register, register7, register4, register6, register2];
12646
12918
 
12647
12919
  // src/program.ts
12648
12920
  var { version, description } = createRequire(import.meta.url)("../package.json");
@@ -12651,7 +12923,7 @@ function buildProgram() {
12651
12923
  program2.addOption(new Option("-n, --dry-run", "print every effect this run would have, perform none").env("PLX_DRY_RUN")).addOption(
12652
12924
  new Option("-v, --verbose", "say more: once for reads and inputs, twice for command output").argParser((_value, previous) => previous + 1).default(0)
12653
12925
  ).addOption(new Option("-q, --quiet", "say nothing but results and errors").env("PLX_QUIET")).addOption(new Option("-y, --yes", "answer every confirmation with yes (needed off a terminal)").env("PLX_YES")).addOption(new Option("--json", "print results as JSON").env("PLX_JSON"));
12654
- for (const register12 of commands) register12(program2);
12926
+ for (const register8 of commands) register8(program2);
12655
12927
  return program2;
12656
12928
  }
12657
12929
  function contextOf(command) {