brnch-cli 0.20.0 → 0.20.1

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 (3) hide show
  1. package/README.md +1 -1
  2. package/brnch.mjs +88 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # brnch 0.20.0
1
+ # brnch 0.20.1
2
2
 
3
3
  Command-line client for [Brnch](https://brnch.io) — Git hosting for humans and agents. Single file, no runtime dependencies, Node 20 or newer.
4
4
 
package/brnch.mjs CHANGED
@@ -31729,6 +31729,9 @@ var DELEGATION_MATRIX = Object.freeze({
31729
31729
  // packages/domain/src/claims.ts
31730
31730
  var PERSON_CLAIM_TTL_MS = 7 * 864e5;
31731
31731
 
31732
+ // packages/domain/src/hosted-machine.ts
31733
+ var MACHINE_CALIBRATION_MS = 1e3;
31734
+
31732
31735
  // packages/domain/src/plans.ts
31733
31736
  var GB = 1024 ** 3;
31734
31737
  var PLANS = Object.freeze({
@@ -35516,7 +35519,10 @@ function runnerProtocolGroup(t) {
35516
35519
  completeCacheSave: (job2, entry, body, o) => t.json({ method: "POST", path: path2`/v1/actions/runner/jobs/${job2}/cache/${entry}/complete`, json: validate2(CompleteUploadRequest, body, "CompleteUploadRequest"), replayable: true, options: o }),
35517
35520
  /** Isolated previews (M6-DEPLOY-CF-16): the upload job's copy of its run's build bundle. */
35518
35521
  deployBundle: (job2, o) => t.bytes({ method: "GET", path: path2`/v1/actions/runner/jobs/${job2}/deploy-bundle`, options: o }),
35519
- /** The machine before the first step (M6-HOSTED-11), so a job the platform ends itself still names it. */
35522
+ /**
35523
+ * The machine before the first step (M6-HOSTED-11), so a job the platform ends itself still names it. A replay after
35524
+ * the platform gave the job back (`given_back`, M6-HOSTED-12) is refused: its token was revoked.
35525
+ */
35520
35526
  machine: (job2, body, o) => t.json({ method: "POST", path: path2`/v1/actions/runner/jobs/${job2}/machine`, json: validate2(MachineStartReport, body, "MachineStartReport"), replayable: true, options: o }),
35521
35527
  complete: (job2, body, o) => t.json({ method: "POST", path: path2`/v1/actions/runner/jobs/${job2}/complete`, json: validate2(CompleteJobRequest, body, "CompleteJobRequest"), replayable: true, options: o }),
35522
35528
  release: (job2, reason, cause, o) => t.json({ method: "POST", path: path2`/v1/actions/runner/jobs/${job2}/release`, json: cause ? { reason, cause } : { reason }, options: o }),
@@ -37498,7 +37504,7 @@ function invalidAgentToolFlag(flag2) {
37498
37504
  }
37499
37505
 
37500
37506
  // packages/cli/package.json
37501
- var version2 = "0.20.0";
37507
+ var version2 = "0.20.1";
37502
37508
 
37503
37509
  // packages/cli/src/brand.ts
37504
37510
  var BRAND = {
@@ -53453,7 +53459,7 @@ ${(await c8.actions.jobs.logs(j.id)).trimEnd()}`);
53453
53459
  })
53454
53460
  );
53455
53461
  run4.command("cancel <run>").description("Cancel a run (running jobs are stopped on their runners)").action(act(rt, async (ctx, _o, [id2]) => ctx.result(await ctx.client("user").actions.runs.cancel(id2), renderRun2)));
53456
- run4.command("rerun <run>").description("Re-run a completed run (all jobs, or --failed and their dependents)").option("--failed", "only failed/cancelled jobs and jobs that depend on them").action(act(rt, async (ctx, o, [id2]) => ctx.result(await ctx.client("user").actions.runs.rerun(id2, { failed_only: !!o.failed }), renderRun2)));
53462
+ run4.command("rerun <run>").description("Re-run a completed run (all jobs, or --failed and their dependents)").option("--failed", "only failed, cancelled and timed-out jobs (in a matrix, only the matrix jobs that failed) and jobs that depend on them").action(act(rt, async (ctx, o, [id2]) => ctx.result(await ctx.client("user").actions.runs.rerun(id2, { failed_only: !!o.failed }), renderRun2)));
53457
53463
  }
53458
53464
  function registerSecret(program2, rt) {
53459
53465
  const secret = program2.command("secret").description("Actions secrets of --repo (default) or the organization (--org-level). Values are write-only");
@@ -53515,7 +53521,7 @@ import { arch, platform } from "node:os";
53515
53521
  import { spawn as spawn5, spawnSync as spawnSync4 } from "node:child_process";
53516
53522
  import { randomUUID } from "node:crypto";
53517
53523
  import { chmodSync as chmodSync3, existsSync as existsSync5, lchownSync as lchownSync2, lstatSync as lstatSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync5, realpathSync as realpathSync5, renameSync, rmSync as rmSync4 } from "node:fs";
53518
- import { gzipSync as gzipSync3 } from "node:zlib";
53524
+ import { gunzipSync as gunzipSync2, gzipSync as gzipSync3 } from "node:zlib";
53519
53525
  import { dirname as dirname21, isAbsolute as isAbsolute12, join as join37, normalize, relative as relative6, resolve as resolve15 } from "node:path";
53520
53526
 
53521
53527
  // packages/cli/src/runner/archive.ts
@@ -55315,7 +55321,7 @@ import { createHash as createHash11 } from "node:crypto";
55315
55321
  import { readFileSync as readFileSync6 } from "node:fs";
55316
55322
  import { availableParallelism, cpus } from "node:os";
55317
55323
  import { join as join34 } from "node:path";
55318
- var CALIBRATION_MS = 1e3;
55324
+ var CALIBRATION_MS = MACHINE_CALIBRATION_MS;
55319
55325
  function parseProcStat(text2) {
55320
55326
  const line2 = text2.split("\n").find((l) => /^cpu\s/.test(l));
55321
55327
  if (!line2) return null;
@@ -56125,8 +56131,9 @@ async function runSecurityScan(spec, host) {
56125
56131
  var CHECKOUT_RETRY_DELAYS_MS = [15e3, 3e4, 6e4, 12e4, 24e4];
56126
56132
  var RPC_FAILED = /\bRPC failed; (?:HTTP (?:429|5\d\d)\b|curl (?:6|7|16|18|28|35|52|55|56|92)\b)/;
56127
56133
  var UNABLE_TO_ACCESS = /\bunable to access '[^']*': (?:The requested URL returned error: (?:429|5\d\d)\b|Failed to connect|Could not resolve host|Connection timed out|Operation timed out|Recv failure|Send failure|Empty reply from server|OpenSSL SSL_)/;
56134
+ var CUT_OFF = /^error: \d+ bytes of (?:body are still expected|length header were received)$|^fatal: early EOF$/;
56128
56135
  function transientGitFailure(line2) {
56129
- return RPC_FAILED.test(line2) || UNABLE_TO_ACCESS.test(line2);
56136
+ return RPC_FAILED.test(line2) || UNABLE_TO_ACCESS.test(line2) || CUT_OFF.test(line2);
56130
56137
  }
56131
56138
  var GONE = /\bRPC failed; HTTP 410\b|\bunable to access '[^']*': The requested URL returned error: 410\b/;
56132
56139
  function jobGoneGitFailure(line2) {
@@ -56261,9 +56268,13 @@ var JobExecution = class {
56261
56268
  killedAt = null;
56262
56269
  /** The job given back to the queue, once (`release`). */
56263
56270
  released = null;
56271
+ /** No step runs any more and none will start: the job's result is known (review of M6-HOSTED-REV-12). */
56272
+ stepsOver = false;
56264
56273
  /** The job ended and its result was reported (or it was given back): nothing more may be sent for it. */
56265
56274
  done = false;
56266
56275
  machine;
56276
+ /** The calibration of the start reading, which steps see (`BRNCH_MACHINE_CALIBRATION`); null until read, or none. */
56277
+ startCalibration = null;
56267
56278
  /**
56268
56279
  * Aborted when the job is cancelled while a step runs: stops that step's transfers and tar processes. A fresh one for
56269
56280
  * every step, so a step that still runs after a cancel or timeout (`if: always()`, e.g. uploading test reports) can
@@ -56312,8 +56323,8 @@ var JobExecution = class {
56312
56323
  }
56313
56324
  }
56314
56325
  /**
56315
- * The platform is replacing the hosted runner: a runner rollout's SIGTERM, then SIGKILL 15 minutes later (ADR-0066 §6
56316
- * and its addendum 2026-10-22). The signal reaches every process in the container, the steps' too, and most tools
56326
+ * The platform is replacing the hosted runner: a runner rollout's SIGTERM, then the container stops, minutes later
56327
+ * (ADR-0066 §6 and its addenda 2026-10-22 and 2026-10-23). The signal reaches every process in the container, the steps' too, and most tools
56317
56328
  * (pnpm, vitest, bash) die of it. So the job keeps running only while nothing fails: a failure no step had before the
56318
56329
  * signal is the rollout's, and the job goes back to the queue (`platform_shutdown`: it runs again on a new runner,
56319
56330
  * uncharged) instead of failing. A job still running after `releaseAfterMs` goes back too.
@@ -56323,7 +56334,7 @@ var JobExecution = class {
56323
56334
  this.drainingSince = Date.now();
56324
56335
  this.notice(`the hosted runner is being replaced by a platform update; the job keeps running for up to ${Math.round(releaseAfterMs / 6e4)} minutes, and runs again on a new runner if a step stops or it is not done by then`);
56325
56336
  void this.client.runner.job.drain(this.jobId).catch(() => void 0);
56326
- setTimeout(() => void this.giveBack("the hosted runner was replaced by a platform update"), releaseAfterMs).unref();
56337
+ setTimeout(() => void (this.stepsOver || this.giveBack("the hosted runner was replaced by a platform update")), releaseAfterMs).unref();
56327
56338
  this.giveBackIfReplaced();
56328
56339
  }
56329
56340
  /** The job's failure came with the rollout (or after it): give the job back rather than report it. */
@@ -56402,6 +56413,10 @@ var JobExecution = class {
56402
56413
  RUNNER_NAME: this.opts.runnerName,
56403
56414
  RUNNER_TEMP: this.temp,
56404
56415
  RUNNER_ENVIRONMENT: this.opts.hosted ? "github-hosted" : "self-hosted",
56416
+ // The hosted container's speed, so a step can give a time limit room on a slower one (Brnch's own migrations
56417
+ // replay budget, M6-QA-06). The start reading, taken as root before any step. The runner never reads it back and
56418
+ // job code may override it like any variable, so nothing trusts it: decisions use the reading the platform stored.
56419
+ ...this.opts.hosted && this.startCalibration !== null ? { BRNCH_MACHINE_CALIBRATION: String(this.startCalibration) } : {},
56405
56420
  ...this.idTokenEnv()
56406
56421
  };
56407
56422
  }
@@ -56553,6 +56568,7 @@ var JobExecution = class {
56553
56568
  for (const n of a.notices ?? []) this.notice(n);
56554
56569
  const machine = this.observe(() => readStart(this.machine));
56555
56570
  if (machine) {
56571
+ this.startCalibration = machine.start.calibration;
56556
56572
  this.notice(startLine(machine));
56557
56573
  await this.reportStart(machine);
56558
56574
  }
@@ -56560,14 +56576,17 @@ var JobExecution = class {
56560
56576
  let outputs = {};
56561
56577
  let error62 = null;
56562
56578
  try {
56563
- rmSync4(this.runnerHome, { recursive: true, force: true });
56564
- mkdirSync3(this.runnerHome, { mode: 448 });
56565
- ({ conclusion, outputs } = await this.runSteps());
56579
+ if (!this.aborted) {
56580
+ rmSync4(this.runnerHome, { recursive: true, force: true });
56581
+ mkdirSync3(this.runnerHome, { mode: 448 });
56582
+ ({ conclusion, outputs } = await this.runSteps());
56583
+ }
56566
56584
  } catch (e) {
56567
56585
  conclusion = "failure";
56568
56586
  error62 = e.message;
56569
56587
  this.log(`##[error]${error62}`);
56570
56588
  }
56589
+ this.stepsOver = true;
56571
56590
  if (this.opts.hosted && conclusion === "failure" && this.killedAt !== null && this.drainingSince === null) await this.pause(this.killedAt + this.drainRaceMs - Date.now());
56572
56591
  if (this.cancelled && !this.aborted) {
56573
56592
  conclusion = "cancelled";
@@ -56607,14 +56626,22 @@ var JobExecution = class {
56607
56626
  /**
56608
56627
  * The start reading goes to the platform before the first step, not only with the completion: a job the platform
56609
56628
  * times out, or cancels past its grace, sends no completion (M6-HOSTED-11). Bounded, and never the job's failure.
56629
+ * The platform may answer that it gave the job back (a slow container, M6-HOSTED-12): it already requeued the job, so
56630
+ * this assignment ends here, with no step, completion or release. The requeue deleted the job's log and revoked its
56631
+ * token, so the reason goes to the runner's own log.
56610
56632
  */
56611
56633
  async reportStart(m) {
56612
56634
  const start = machineStart(this.machine, m);
56613
56635
  if (!start) return;
56614
56636
  try {
56615
- await this.client.runner.job.machine(this.jobId, { machine: start }, { signal: AbortSignal.timeout(REPORT_START_TIMEOUT_MS) });
56637
+ const r = await this.client.runner.job.machine(this.jobId, { machine: start }, { signal: AbortSignal.timeout(REPORT_START_TIMEOUT_MS) });
56638
+ if (r.given_back) {
56639
+ this.opts.diag(`given back by the platform before the first step: ${r.given_back.message}`);
56640
+ this.abort(`given back by the platform (${r.given_back.reason})`);
56641
+ }
56616
56642
  } catch (e) {
56617
- this.opts.diag(`warning: could not report the machine's facts: ${e.message}`);
56643
+ if (isBrnchApiError(e) && (e.status === 401 || e.status === 412)) this.abort("job token revoked (the job was cancelled, timed out or reassigned)");
56644
+ else this.opts.diag(`warning: could not report the machine's facts: ${e.message}`);
56618
56645
  }
56619
56646
  }
56620
56647
  /** Reading the machine is observability: a failure loses the facts, never the job. */
@@ -56874,6 +56901,7 @@ var JobExecution = class {
56874
56901
  gone = false;
56875
56902
  return this.exec(["git", ...args], { cwd: this.workspace, env, asStepUser: false, timeoutMs: 30 * 6e4, onLine });
56876
56903
  };
56904
+ const delays = this.opts.checkoutRetryDelaysMs ?? CHECKOUT_RETRY_DELAYS_MS;
56877
56905
  const fetchCommit = async (args) => {
56878
56906
  for (let attempt = 0; ; attempt++) {
56879
56907
  const code2 = await git4(["fetch", ...args]);
@@ -56881,13 +56909,13 @@ var JobExecution = class {
56881
56909
  await this.refreshState();
56882
56910
  return this.cancelled ? null : code2;
56883
56911
  }
56884
- const delay = CHECKOUT_RETRY_DELAYS_MS[attempt];
56912
+ const delay = delays[attempt];
56885
56913
  if (code2 === 0 || code2 === null || !transient) return code2;
56886
56914
  if (delay === void 0 || Date.now() + delay >= this.deadline) {
56887
56915
  this.log("##[error]the server kept failing the fetch; the repository's storage may be busy. Run the job again in a few minutes");
56888
56916
  return code2;
56889
56917
  }
56890
- this.log(`##[brnch] the server could not answer the fetch; retrying in ${delay / 1e3} s (attempt ${attempt + 2} of ${CHECKOUT_RETRY_DELAYS_MS.length + 1})`);
56918
+ this.log(`##[brnch] the server could not answer the fetch; retrying in ${delay / 1e3} s (attempt ${attempt + 2} of ${delays.length + 1})`);
56891
56919
  if (!await this.pause(delay)) return null;
56892
56920
  }
56893
56921
  };
@@ -56898,8 +56926,9 @@ var JobExecution = class {
56898
56926
  for (const args of [["init", "-q"], ["remote", "add", "origin", this.gitUrl()]]) if (await git4(args) !== 0) return 1;
56899
56927
  let code2;
56900
56928
  if (depth > 0) {
56929
+ const killedBefore = this.killedAt;
56901
56930
  code2 = await fetchCommit(["--no-tags", "--prune", `--depth=${depth}`, "origin", a.head_sha]);
56902
- if (code2 !== 0 && code2 !== null && !transient && !gone) {
56931
+ if (code2 !== 0 && code2 !== null && !transient && !gone && this.killedAt === killedBefore) {
56903
56932
  this.log("##[brnch] the shallow fetch failed; fetching full history of the commit");
56904
56933
  code2 = await fetchCommit(["--no-tags", "origin", a.head_sha]);
56905
56934
  }
@@ -57469,21 +57498,33 @@ var JobExecution = class {
57469
57498
  }
57470
57499
  if (!files.length) return fail(`no .sarif files found at ${w.sarif_file}`);
57471
57500
  const checkout = w.checkout_path ? resolve15(this.workspace, w.checkout_path) : this.workspace;
57501
+ const readFile15 = (f) => {
57502
+ const shown = relative6(this.workspace, f);
57503
+ const read2 = this.owner ? spawnSync4("cat", ["--", f], { uid: this.owner.uid, gid: this.owner.gid, maxBuffer: MAX_SARIF_FILE_BYTES + 1 }) : null;
57504
+ if (read2 && (read2.error || read2.status !== 0)) return `cannot read ${shown}: ${String(read2.error ?? read2.stderr ?? "").trim() || "too large"}`;
57505
+ const raw = read2 ? new Uint8Array(read2.stdout) : new Uint8Array(readNoFollowBytes(f));
57506
+ if (raw.byteLength > MAX_SARIF_FILE_BYTES) return `${shown} is larger than ${MAX_SARIF_FILE_BYTES} bytes`;
57507
+ return raw;
57508
+ };
57472
57509
  const seen = /* @__PURE__ */ new Map();
57510
+ for (const f of files) {
57511
+ const raw = readFile15(f);
57512
+ if (typeof raw === "string") return fail(raw);
57513
+ for (const key of sarifRunKeys(raw, w.category ?? "")) {
57514
+ const [tool, category] = key.split("\0");
57515
+ if (seen.has(key) && seen.get(key) !== relative6(this.workspace, f)) return fail(`${relative6(this.workspace, f)} and ${seen.get(key)} both have results for ${tool} in category "${category}"; give each file its own category (runAutomationDetails.id)`);
57516
+ seen.set(key, relative6(this.workspace, f));
57517
+ }
57518
+ }
57473
57519
  const ids = [];
57474
57520
  for (const f of files) {
57475
57521
  const shown = relative6(this.workspace, f);
57476
- const read2 = this.owner ? spawnSync4("cat", ["--", f], { uid: this.owner.uid, gid: this.owner.gid, maxBuffer: MAX_SARIF_FILE_BYTES + 1 }) : null;
57477
- if (read2 && (read2.error || read2.status !== 0)) return fail(`cannot read ${shown}: ${String(read2.error ?? read2.stderr ?? "").trim() || "too large"}`);
57478
- const raw = read2 ? new Uint8Array(read2.stdout) : new Uint8Array(readNoFollowBytes(f));
57479
- if (raw.byteLength > MAX_SARIF_FILE_BYTES) return fail(`${shown} is larger than ${MAX_SARIF_FILE_BYTES} bytes`);
57522
+ const raw = readFile15(f);
57523
+ if (typeof raw === "string") return fail(raw);
57480
57524
  const body = raw[0] === 31 && raw[1] === 139 ? raw : new Uint8Array(gzipSync3(raw));
57481
57525
  try {
57482
57526
  const r = await this.client.runner.job.codeScanning(this.jobId, body, { ...w.category ? { category: w.category } : {}, checkout_path: checkout });
57483
57527
  for (const a of r.analyses) {
57484
- const key = `${a.tool.name}\0${a.category}`;
57485
- if (seen.has(key)) return fail(`${shown} and ${seen.get(key)} both have results for ${a.tool.name} in category "${a.category}"; give each file its own category (runAutomationDetails.id)`);
57486
- seen.set(key, shown);
57487
57528
  this.log(`uploaded ${shown}: ${a.tool.name}${a.category ? ` (${a.category})` : ""}, ${a.finding_count} result${a.finding_count === 1 ? "" : "s"}${a.truncated ? " (truncated)" : ""}, ${a.trust.independence.replace("_", "-")}`);
57488
57529
  }
57489
57530
  ids.push(r.upload_id);
@@ -57496,6 +57537,21 @@ var JobExecution = class {
57496
57537
  }
57497
57538
  };
57498
57539
  var MAX_SARIF_FILE_BYTES = 10 * 1024 * 1024;
57540
+ function sarifRunKeys(raw, category) {
57541
+ try {
57542
+ const bytes = raw[0] === 31 && raw[1] === 139 ? gunzipSync2(raw, { maxOutputLength: 32 * 1024 * 1024 }) : raw;
57543
+ const doc = JSON.parse(new TextDecoder().decode(bytes));
57544
+ return (Array.isArray(doc.runs) ? doc.runs : []).flatMap((r) => {
57545
+ const name = r?.tool?.driver?.name;
57546
+ if (typeof name !== "string") return [];
57547
+ const id2 = r.automationDetails?.id;
57548
+ const cat = typeof id2 === "string" && id2 !== "" ? id2.lastIndexOf("/") < 0 ? "" : id2.slice(0, id2.lastIndexOf("/")) : category.trim();
57549
+ return [`${name}\0${cat}`];
57550
+ });
57551
+ } catch {
57552
+ return [];
57553
+ }
57554
+ }
57499
57555
  var MAX_SARIF_FILES = 20;
57500
57556
  function sarifFiles(workspace, target) {
57501
57557
  const root = realpathSync5(workspace);
@@ -58656,6 +58712,13 @@ function openUrl(url2) {
58656
58712
  }
58657
58713
  });
58658
58714
  }
58715
+ function workingDirectory() {
58716
+ try {
58717
+ return process.cwd();
58718
+ } catch {
58719
+ return process.env.PWD || "/";
58720
+ }
58721
+ }
58659
58722
  function nodeDeps() {
58660
58723
  return {
58661
58724
  stdout: process.stdout,
@@ -58665,7 +58728,7 @@ function nodeDeps() {
58665
58728
  stderrIsTTY: !!process.stderr.isTTY,
58666
58729
  stdoutIsTTY: !!process.stdout.isTTY,
58667
58730
  env: process.env,
58668
- cwd: process.cwd(),
58731
+ cwd: workingDirectory(),
58669
58732
  createClient,
58670
58733
  spawnGit,
58671
58734
  spawnTool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brnch-cli",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Brnch CLI: Git hosting for humans and agents, from the command line",
5
5
  "keywords": [
6
6
  "git",