@geonosis/release 2.5.0 → 2.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # @geonosis/release
2
2
 
3
+ ## 2.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7f24da6: `geonosis-release smoke baseline`/`smoke run --rc-dir` now write, beside the tarballs `packRc`
8
+ makes, a `candidate/` scratch package pinned to the candidate's own `@geonosis/cli` tarball (an
9
+ override for every packed name, in `package.json` and — pnpm actually reads it from —
10
+ `pnpm-workspace.yaml`), and print `candidate CLI: (cd <dir>/candidate && pnpm install
11
+ --ignore-scripts && pnpm exec geonosis update --registry <dir> --to <version> …)` (#389). A
12
+ consumer at 2.4.3 cannot run `update --registry <dir>` at all (it parses the directory as a URL),
13
+ and a bare tarball install cannot resolve its own siblings either (#387) — the adoption story had
14
+ no first step. `docs/releasing.md`'s RC section carries the line as that first step.
15
+ - 7f24da6: A machine-wide heavy budget, two slots, held by every tool that spends one (#385): measured load 48
16
+ with during.day's Stop-hook tier, this kit's Stop-hook tier, its ratchet and an engineer's suite all
17
+ running at once — nothing held the two-slot budget across the two repos, and three kit tests read
18
+ red under it that were green alone. `@geonosis/ratchet` ships `takeSlot`/`heavySlotDir` (a lock
19
+ directory under `os.tmpdir()/geonosis-heavy`, two slot files each holding `{ pid, command, cwd,
20
+ startedAt }`, a dead holder reaped on the next take, a taker that finds both held waiting once
21
+ before refusing naming them); `geonosis-verify` (each tier run) and `geonosis-ratchet` (the run, not
22
+ `--prove`'s inner unit) take a slot for the run's duration. `@geonosis/release`'s `smoke
23
+ baseline`/`smoke run` take one too, from their own copy (this layer imports nothing, same posture as
24
+ `envelope.ts`). The plugin's Stop hook reads a fresh green `gate-report.<tier>.json` — its own
25
+ recorded commit equal to the current `HEAD` with a clean tree — and reports PASS without re-running;
26
+ otherwise it peeks the same two slots (never claims one itself, or the verify child it is about to
27
+ spawn would be racing against its own reservation) and blocks naming both holders rather than
28
+ running a third tier.
29
+ - 7f24da6: The smoke's run copy sits where nothing is above it (#373's true root): it used to land at
30
+ `<root>/.geonosis/consumer-snapshots/<name>/run`, an ancestor of which (`<root>` itself) carries
31
+ this kit's own `node_modules` — so a package a consumer never installed resolved to the kit's own
32
+ workspace copy, two directories up, and every tool run inside the copy (the doctor included) read
33
+ it as theirs. The copy now lands under the OS temp directory instead, as `packages/verify`'s prove
34
+ copy does; its path is printed as `run copy: <path>` and it is removed on a passing run and kept
35
+ (for reading) on a failing one.
36
+ - 7f24da6: The smoke runner owns its child's lifetime (#368): two `pnpm install` processes from a 2026-09-03
37
+ smoke were still alive two days later, holding pnpm's store lock every other install on the machine
38
+ then waits on. Every install and phase now spawns in its own process group with a timeout (default
39
+ 20 minutes, `release.smoke.timeoutMs` overrides it) and is killed by the WHOLE group — SIGTERM, then
40
+ SIGKILL after a grace period — on timeout; a timed-out phase is recorded as a fail with the tail
41
+ `timed out after <n> ms — killed`.
42
+
3
43
  ## 2.5.0
4
44
 
5
45
  ### Patch Changes
@@ -9,11 +9,19 @@ import { dirname, join } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
10
 
11
11
  const here = dirname(fileURLToPath(import.meta.url))
12
- const newest = (dir) =>
12
+ // tsup never reads a test file, so one counting as source made every bin refuse a build that had
13
+ // not gone stale (#399).
14
+ const NOT_SOURCE = (name) =>
15
+ name.endsWith('.test.ts') ||
16
+ name.endsWith('.test.tsx') ||
17
+ name === '__tests__' ||
18
+ name === '__fixtures__'
19
+ const newest = (dir, skip) =>
13
20
  existsSync(dir)
14
21
  ? readdirSync(dir, { withFileTypes: true }).reduce((most, entry) => {
22
+ if (skip !== undefined && skip(entry.name)) return most
15
23
  const at = join(dir, entry.name)
16
- return Math.max(most, entry.isDirectory() ? newest(at) : statSync(at).mtimeMs)
24
+ return Math.max(most, entry.isDirectory() ? newest(at, skip) : statSync(at).mtimeMs)
17
25
  }, 0)
18
26
  : 0
19
27
  const src = join(here, '..', 'src')
@@ -30,7 +38,7 @@ if (existsSync(src)) {
30
38
  )
31
39
  process.exit(2)
32
40
  }
33
- if (newest(src) > built) {
41
+ if (newest(src, NOT_SOURCE) > built) {
34
42
  process.stderr.write(
35
43
  'geonosis-release: dist is older than src — run pnpm build before trusting this bin.\n',
36
44
  )
@@ -97,7 +97,7 @@ var parseSchema = (value2) => {
97
97
  sessionVariables: strings(value2["sessionVariables"], "release.schema.sessionVariables")
98
98
  };
99
99
  };
100
- var SMOKE_KEYS = "expected { exclude?, snapshots? }";
100
+ var SMOKE_KEYS = "expected { exclude?, snapshots?, timeoutMs? }";
101
101
  var SNAPSHOT_KEYS = "expected { commands?, install?, name }";
102
102
  var SMOKE_PHASES = PHASES;
103
103
  var parseCommands = (value2, at) => {
@@ -146,10 +146,11 @@ var oneSnapshot = (value2, index) => {
146
146
  name
147
147
  };
148
148
  };
149
+ var KNOWN_SMOKE_KEYS = /* @__PURE__ */ new Set(["exclude", "snapshots", "timeoutMs"]);
149
150
  var parseSmoke = (value2) => {
150
151
  if (value2 === void 0) return { exclude: [], snapshots: [] };
151
152
  if (!isRecord(value2)) throw new CannotRun(`release.smoke must be an object \u2014 ${SMOKE_KEYS}`);
152
- const unknown = Object.keys(value2).find((key) => key !== "exclude" && key !== "snapshots");
153
+ const unknown = Object.keys(value2).find((key) => !KNOWN_SMOKE_KEYS.has(key));
153
154
  if (unknown !== void 0) {
154
155
  throw new CannotRun(`release.smoke.${unknown} is not a key it reads \u2014 ${SMOKE_KEYS}`);
155
156
  }
@@ -157,9 +158,14 @@ var parseSmoke = (value2) => {
157
158
  if (snapshots !== void 0 && !Array.isArray(snapshots)) {
158
159
  throw new CannotRun(`release.smoke.snapshots must be a list \u2014 ${SNAPSHOT_KEYS}, per entry`);
159
160
  }
161
+ const timeoutMs = value2["timeoutMs"];
162
+ if (timeoutMs !== void 0 && (typeof timeoutMs !== "number" || timeoutMs <= 0)) {
163
+ throw new CannotRun("release.smoke.timeoutMs must be a positive number of milliseconds");
164
+ }
160
165
  return {
161
166
  exclude: strings(value2["exclude"], "release.smoke.exclude"),
162
- snapshots: (snapshots ?? []).map(oneSnapshot)
167
+ snapshots: (snapshots ?? []).map(oneSnapshot),
168
+ ...timeoutMs === void 0 ? {} : { timeoutMs }
163
169
  };
164
170
  };
165
171
  var parseReleaseConfig = (raw) => {
@@ -366,9 +372,18 @@ var askRegistry = async ({
366
372
  };
367
373
 
368
374
  // src/published.ts
369
- import { readdirSync, readFileSync as readFileSync3 } from "fs";
375
+ import { readFileSync as readFileSync3 } from "fs";
376
+
377
+ // src/manifests.ts
378
+ import { readdirSync } from "fs";
370
379
  import { join as join2, relative, sep } from "path";
371
- var NEVER_WALKED = /* @__PURE__ */ new Set(["build", "coverage", "dist", "node_modules", "storybook-static"]);
380
+ var NEVER_WALKED = /* @__PURE__ */ new Set([
381
+ "build",
382
+ "coverage",
383
+ "dist",
384
+ "node_modules",
385
+ "storybook-static"
386
+ ]);
372
387
  var pathOf = (root, path) => relative(root, path).split(sep).join("/");
373
388
  var manifestsUnder = (root) => {
374
389
  const found = [];
@@ -391,6 +406,8 @@ var manifestsUnder = (root) => {
391
406
  walk(root);
392
407
  return found;
393
408
  };
409
+
410
+ // src/published.ts
394
411
  var censusOf = (root) => {
395
412
  const excused = [];
396
413
  const locals = [];
@@ -1178,16 +1195,16 @@ var declaredIn = (config) => {
1178
1195
  routes: listed.map((route) => isRecord3(route) ? route["pattern"] : route).filter((pattern) => typeof pattern === "string").toSorted()
1179
1196
  };
1180
1197
  };
1181
- var readWrangler = (root, relative6, env) => {
1182
- const path = resolve4(root, relative6);
1198
+ var readWrangler = (root, relative4, env) => {
1199
+ const path = resolve4(root, relative4);
1183
1200
  let parsed;
1184
1201
  try {
1185
1202
  const source = readFileSync6(path, "utf8");
1186
- parsed = relative6.endsWith(".toml") ? parseToml(source) : parseJsonc(source);
1203
+ parsed = relative4.endsWith(".toml") ? parseToml(source) : parseJsonc(source);
1187
1204
  } catch (error) {
1188
- throw new CannotRun(`${relative6} could not be read: ${error.message}`);
1205
+ throw new CannotRun(`${relative4} could not be read: ${error.message}`);
1189
1206
  }
1190
- if (!isRecord3(parsed)) throw new CannotRun(`${relative6} is not a wrangler configuration`);
1207
+ if (!isRecord3(parsed)) throw new CannotRun(`${relative4} is not a wrangler configuration`);
1191
1208
  const environments = parsed["env"];
1192
1209
  const block = env !== void 0 && isRecord3(environments) && isRecord3(environments[env]) ? environments[env] : parsed;
1193
1210
  return declaredIn(block);
@@ -1987,9 +2004,18 @@ var formatSchema = (report) => {
1987
2004
  };
1988
2005
 
1989
2006
  // src/smoke-run.ts
1990
- import { spawnSync as spawnSync4 } from "child_process";
1991
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync5, readFileSync as readFileSync10, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
1992
- import { join as join7, relative as relative4, resolve as resolve9 } from "path";
2007
+ import { spawn as spawn2, spawnSync as spawnSync4 } from "child_process";
2008
+ import {
2009
+ existsSync as existsSync7,
2010
+ mkdirSync as mkdirSync3,
2011
+ mkdtempSync as mkdtempSync2,
2012
+ readdirSync as readdirSync5,
2013
+ readFileSync as readFileSync10,
2014
+ rmSync as rmSync3,
2015
+ writeFileSync as writeFileSync4
2016
+ } from "fs";
2017
+ import { tmpdir as tmpdir2 } from "os";
2018
+ import { join as join7, resolve as resolve9 } from "path";
1993
2019
  var BASELINE_FILE = "baseline.json";
1994
2020
  var INSTALL = {
1995
2021
  bun: ["install"],
@@ -2098,7 +2124,7 @@ var TOP_LEVEL = /^\S/;
2098
2124
  var KEYED = /^\s+["']?(.+?)["']?\s*:/;
2099
2125
  var pinsInWorkspaceYaml = (work, pins) => {
2100
2126
  const at = join7(work, "pnpm-workspace.yaml");
2101
- const written = Object.entries(pins).map(([name, spec]) => ` '${name}': '${spec}'`);
2127
+ const written = Object.entries(pins).filter((entry) => entry[1] !== void 0).map(([name, spec]) => ` '${name}': '${spec}'`);
2102
2128
  const lines2 = existsSync7(at) ? readFileSync10(at, "utf8").split("\n") : [];
2103
2129
  const kept = [];
2104
2130
  let inside = false;
@@ -2111,10 +2137,10 @@ var pinsInWorkspaceYaml = (work, pins) => {
2111
2137
  continue;
2112
2138
  }
2113
2139
  const key = inside ? KEYED.exec(line)?.[1] : void 0;
2114
- if (key !== void 0 && pins[key] !== void 0) continue;
2140
+ if (key !== void 0 && Object.hasOwn(pins, key)) continue;
2115
2141
  kept.push(line);
2116
2142
  }
2117
- const body = found ? kept : [...kept, OVERRIDES, ...written, ""];
2143
+ const body = found ? kept : written.length === 0 ? kept : [...kept, OVERRIDES, ...written, ""];
2118
2144
  writeFileSync4(at, body.join("\n"));
2119
2145
  };
2120
2146
  var pinsInManifest = (work, pins) => {
@@ -2122,13 +2148,42 @@ var pinsInManifest = (work, pins) => {
2122
2148
  if (!existsSync7(at)) return;
2123
2149
  const manifest = readJson(at);
2124
2150
  if (!isRecord5(manifest)) return;
2125
- manifest["overrides"] = {
2126
- ...isRecord5(manifest["overrides"]) ? manifest["overrides"] : {},
2127
- ...pins
2128
- };
2151
+ const next = isRecord5(manifest["overrides"]) ? { ...manifest["overrides"] } : {};
2152
+ for (const [name, spec] of Object.entries(pins)) {
2153
+ if (spec === void 0) delete next[name];
2154
+ else next[name] = spec;
2155
+ }
2156
+ manifest["overrides"] = next;
2129
2157
  writeFileSync4(at, `${JSON.stringify(manifest, void 0, 2)}
2130
2158
  `);
2131
2159
  };
2160
+ var CANDIDATE_CLI = "@geonosis/cli";
2161
+ var candidateDirIn = (into, packed) => {
2162
+ const cli = packed.find((one) => one.name === CANDIDATE_CLI);
2163
+ if (cli === void 0) return void 0;
2164
+ const dir = join7(into, "candidate");
2165
+ mkdirSync3(dir, { recursive: true });
2166
+ const pins = Object.fromEntries(packed.map((one) => [one.name, `file:${one.tarball}`]));
2167
+ writeFileSync4(
2168
+ join7(dir, "package.json"),
2169
+ `${JSON.stringify(
2170
+ {
2171
+ devDependencies: { [CANDIDATE_CLI]: `file:${cli.tarball}` },
2172
+ name: "geonosis-candidate",
2173
+ overrides: pins,
2174
+ private: true
2175
+ },
2176
+ void 0,
2177
+ 2
2178
+ )}
2179
+ `
2180
+ );
2181
+ pinsInWorkspaceYaml(dir, pins);
2182
+ return {
2183
+ line: `candidate CLI: (cd ${dir} && pnpm install --ignore-scripts && pnpm exec geonosis update --registry ${into} --to ${cli.version} \u2026)`,
2184
+ path: dir
2185
+ };
2186
+ };
2132
2187
  var overrideEveryCopy = (work, manager, packed) => {
2133
2188
  if (packed.length === 0) return;
2134
2189
  const pins = Object.fromEntries(packed.map((one) => [one.name, `file:${one.tarball}`]));
@@ -2160,9 +2215,62 @@ var rewriteManifests = (tree, manifests, packed) => {
2160
2215
  }
2161
2216
  return swapped;
2162
2217
  };
2163
- var shellRun = (command, cwd, env) => {
2164
- const done = spawnSync4(command, { cwd, encoding: "utf8", env, shell: true });
2165
- return { code: done.status ?? -1, output: `${done.stdout ?? ""}${done.stderr ?? ""}` };
2218
+ var DEFAULT_PHASE_TIMEOUT_MS = 20 * 6e4;
2219
+ var KILL_GRACE_MS = 5e3;
2220
+ var groupsThisProcessOwns = /* @__PURE__ */ new Set();
2221
+ var killGroup = (pid, signal) => {
2222
+ try {
2223
+ process.kill(-pid, signal);
2224
+ } catch {
2225
+ }
2226
+ };
2227
+ var ownsItsExit = false;
2228
+ var ownEveryGroupOnExit = () => {
2229
+ if (ownsItsExit) return;
2230
+ ownsItsExit = true;
2231
+ const sweep = () => {
2232
+ for (const pid of groupsThisProcessOwns) killGroup(pid, "SIGKILL");
2233
+ };
2234
+ process.once("exit", sweep);
2235
+ process.once("SIGINT", sweep);
2236
+ process.once("SIGTERM", sweep);
2237
+ };
2238
+ var shellRun = (command, cwd, env, timeoutMs = DEFAULT_PHASE_TIMEOUT_MS) => {
2239
+ ownEveryGroupOnExit();
2240
+ return new Promise((settle) => {
2241
+ const child = spawn2(command, {
2242
+ cwd,
2243
+ detached: true,
2244
+ env,
2245
+ shell: true,
2246
+ stdio: ["ignore", "pipe", "pipe"]
2247
+ });
2248
+ const pid = child.pid;
2249
+ if (pid !== void 0) groupsThisProcessOwns.add(pid);
2250
+ let output = "";
2251
+ child.stdout?.on("data", (chunk) => {
2252
+ output += chunk.toString();
2253
+ });
2254
+ child.stderr?.on("data", (chunk) => {
2255
+ output += chunk.toString();
2256
+ });
2257
+ let timedOut = false;
2258
+ let graceTimer;
2259
+ const deadline = setTimeout(() => {
2260
+ timedOut = true;
2261
+ if (pid === void 0) return;
2262
+ killGroup(pid, "SIGTERM");
2263
+ graceTimer = setTimeout(() => killGroup(pid, "SIGKILL"), KILL_GRACE_MS);
2264
+ }, timeoutMs);
2265
+ const finish = (code) => {
2266
+ clearTimeout(deadline);
2267
+ if (graceTimer !== void 0) clearTimeout(graceTimer);
2268
+ if (pid !== void 0) groupsThisProcessOwns.delete(pid);
2269
+ settle({ code: code ?? -1, output, timedOut });
2270
+ };
2271
+ child.on("error", () => finish(-1));
2272
+ child.on("close", (code) => finish(code));
2273
+ });
2166
2274
  };
2167
2275
  var INTERESTING = /\b(error|Error|ERROR|FAIL|failed|refused|✗|✘)\b/;
2168
2276
  var quotable = (output) => {
@@ -2175,14 +2283,23 @@ var GIT_REFUSAL = /fatal: not a git repository|Command failed: git |git .+ faile
2175
2283
  var looksLikeAGitRefusal = (output) => output.split("\n").some((line) => GIT_REFUSAL.test(line));
2176
2284
  var hasNoCommit = (dir) => spawnSync4("git", ["-C", dir, "rev-parse", "--verify", "-q", "HEAD"], { encoding: "utf8" }).status !== 0;
2177
2285
  var NOT_MEASURED_REASON = "this gate asks git and a snapshot has none";
2178
- var runPhase = (phase, command, cwd, manager) => {
2286
+ var runPhase = async (phase, command, cwd, manager, timeoutMs = DEFAULT_PHASE_TIMEOUT_MS) => {
2179
2287
  if ("why" in command) return { phase, why: command.why };
2180
2288
  const line = commandLine(command, manager);
2181
2289
  const env = {
2182
2290
  ...process.env,
2183
2291
  PATH: `${join7(cwd, "node_modules/.bin")}:${process.env["PATH"] ?? ""}`
2184
2292
  };
2185
- const { code, output } = shellRun(line, cwd, env);
2293
+ const { code, output, timedOut } = await shellRun(line, cwd, env, timeoutMs);
2294
+ if (timedOut) {
2295
+ return {
2296
+ code: 124,
2297
+ command: line,
2298
+ lines: [...quotable(output), `timed out after ${timeoutMs} ms \u2014 killed`],
2299
+ ok: false,
2300
+ phase
2301
+ };
2302
+ }
2186
2303
  if (code !== 0 && looksLikeAGitRefusal(output) && hasNoCommit(cwd)) {
2187
2304
  return { command: line, phase, unmeasurable: NOT_MEASURED_REASON };
2188
2305
  }
@@ -2194,53 +2311,70 @@ var commandsToRun = (record, declared) => Object.fromEntries(
2194
2311
  return [phase, named === void 0 ? record.commands[phase] : { exec: named }];
2195
2312
  })
2196
2313
  );
2197
- var smokeOver = (input) => {
2314
+ var smokeOver = async (input) => {
2315
+ const timeoutMs = readReleaseConfig(input.root).smoke?.timeoutMs ?? DEFAULT_PHASE_TIMEOUT_MS;
2198
2316
  const record = readSnapshot(input.root, input.name);
2199
2317
  const at = snapshotDir(input.root, input.name);
2200
- const work = join7(at, "run");
2201
- rmSync3(work, { force: true, recursive: true });
2202
- copyInto(join7(at, "tree"), work, new Set(EXCLUDED_DIRS));
2203
- ownRepository(work);
2204
- const packed = input.rc === void 0 ? [] : packRc(resolve9(input.rc), join7(at, "rc"));
2205
- const findings = input.rc === void 0 ? [] : promisedButNotPacked(resolve9(input.rc), packed);
2206
- const swapped = rewriteManifests(
2207
- work,
2208
- record.manifests.map((one) => one.path),
2209
- packed
2210
- );
2211
- if (input.rc !== void 0 && swapped.length === 0) {
2212
- throw new CannotRun(
2213
- `no manifest in "${input.name}" depends on anything ${input.rc} packs \u2014 this run would have installed the published versions and called them the release candidate`
2318
+ const work = mkdtempSync2(join7(tmpdir2(), "geonosis-smoke-"));
2319
+ try {
2320
+ copyInto(join7(at, "tree"), work, new Set(EXCLUDED_DIRS));
2321
+ ownRepository(work);
2322
+ const rcDir = join7(at, "rc");
2323
+ const packed = input.rc === void 0 ? [] : packRc(resolve9(input.rc), rcDir);
2324
+ const candidate = input.rc === void 0 ? void 0 : candidateDirIn(rcDir, packed)?.line;
2325
+ const findings = input.rc === void 0 ? [] : promisedButNotPacked(resolve9(input.rc), packed);
2326
+ const swapped = rewriteManifests(
2327
+ work,
2328
+ record.manifests.map((one) => one.path),
2329
+ packed
2214
2330
  );
2215
- }
2216
- overrideEveryCopy(work, record.manager, packed);
2217
- const frozen = input.rc === void 0;
2218
- const install = input.install ?? defaultInstallLine(record.manager, frozen);
2219
- const configBefore = configBytesOf(work);
2220
- const installed = shellRun(install, work, process.env);
2221
- if (installed.code !== 0) {
2222
- if (frozen) {
2331
+ if (input.rc !== void 0 && swapped.length === 0) {
2223
2332
  throw new CannotRun(
2224
- `\`${install}\` exited ${installed.code} in the snapshot \u2014 the baseline is the consumer's own tree and their lockfile does not describe it \u2014 record it after they install:
2333
+ `no manifest in "${input.name}" depends on anything ${input.rc} packs \u2014 this run would have installed the published versions and called them the release candidate`
2334
+ );
2335
+ }
2336
+ overrideEveryCopy(work, record.manager, packed);
2337
+ const frozen = input.rc === void 0;
2338
+ const install = input.install ?? defaultInstallLine(record.manager, frozen);
2339
+ const configBefore = configBytesOf(work);
2340
+ const installed = await shellRun(install, work, process.env, timeoutMs);
2341
+ if (installed.timedOut) {
2342
+ throw new CannotRun(
2343
+ `\`${install}\` timed out after ${timeoutMs} ms in the snapshot and was killed \u2014 nothing below it was measured:
2225
2344
  ${quotable(installed.output).join("\n")}`
2226
2345
  );
2227
2346
  }
2228
- throw new CannotRun(
2229
- `\`${install}\` exited ${installed.code} in the snapshot \u2014 the release was never installed, so nothing below it was measured:
2347
+ if (installed.code !== 0) {
2348
+ if (frozen) {
2349
+ throw new CannotRun(
2350
+ `\`${install}\` exited ${installed.code} in the snapshot \u2014 the baseline is the consumer's own tree and their lockfile does not describe it \u2014 record it after they install:
2230
2351
  ${quotable(installed.output).join("\n")}`
2231
- );
2232
- }
2233
- for (const file of editedByInstall(configBefore, configBytesOf(work))) {
2234
- findings.push(
2235
- `\`${install}\` rewrote ${file} in the copy \u2014 every phase below ran against a tree that is no longer byte-for-byte "${input.name}", and the same install in their own tree would edit theirs`
2236
- );
2352
+ );
2353
+ }
2354
+ throw new CannotRun(
2355
+ `\`${install}\` exited ${installed.code} in the snapshot \u2014 the release was never installed, so nothing below it was measured:
2356
+ ${quotable(installed.output).join("\n")}`
2357
+ );
2358
+ }
2359
+ for (const file of editedByInstall(configBefore, configBytesOf(work))) {
2360
+ findings.push(
2361
+ `\`${install}\` rewrote ${file} in the copy \u2014 every phase below ran against a tree that is no longer byte-for-byte "${input.name}", and the same install in their own tree would edit theirs`
2362
+ );
2363
+ }
2364
+ const commands = commandsToRun(record, input.commands ?? {});
2365
+ const outcomes = [];
2366
+ for (const phase of PHASES) {
2367
+ outcomes.push(await runPhase(phase, commands[phase], work, record.manager, timeoutMs));
2368
+ }
2369
+ if (outcomes.every((one) => !("ok" in one) || one.ok)) {
2370
+ rmSync3(work, { force: true, recursive: true });
2371
+ }
2372
+ return { ...candidate === void 0 ? {} : { candidate }, findings, outcomes, packed, work };
2373
+ } catch (error) {
2374
+ const message = error instanceof Error ? error.message : String(error);
2375
+ throw new CannotRun(`${message}
2376
+ run copy: ${work}`);
2237
2377
  }
2238
- const commands = commandsToRun(record, input.commands ?? {});
2239
- return {
2240
- findings,
2241
- outcomes: PHASES.map((phase) => runPhase(phase, commands[phase], work, record.manager)),
2242
- packed
2243
- };
2244
2378
  };
2245
2379
  var ownRepository = (work) => {
2246
2380
  const done = spawnSync4("git", ["init", "-q"], { cwd: work });
@@ -2251,13 +2385,15 @@ var ownRepository = (work) => {
2251
2385
  }
2252
2386
  };
2253
2387
  var baselinePath = (root, name) => join7(snapshotDir(root, name), BASELINE_FILE);
2254
- var recordBaseline = (input) => {
2255
- const { outcomes, packed } = smokeOver(input);
2388
+ var recordBaseline = async (input) => {
2389
+ const { candidate, outcomes, packed, work } = await smokeOver(input);
2256
2390
  const baseline = {
2257
2391
  at: (/* @__PURE__ */ new Date()).toISOString(),
2392
+ ...candidate === void 0 ? {} : { candidate },
2258
2393
  packed: packed.map((one) => ({ name: one.name, version: one.version })),
2259
2394
  phases: outcomes,
2260
- rc: input.rc === void 0 ? "the versions the tree names" : resolve9(input.rc)
2395
+ rc: input.rc === void 0 ? "the versions the tree names" : resolve9(input.rc),
2396
+ ...existsSync7(work) ? { runCopy: work } : {}
2261
2397
  };
2262
2398
  writeFileSync4(baselinePath(input.root, input.name), `${JSON.stringify(baseline, void 0, 2)}
2263
2399
  `);
@@ -2315,10 +2451,14 @@ var compareToBaseline = (name, baseline, outcomes, findings) => {
2315
2451
  refused
2316
2452
  };
2317
2453
  };
2318
- var runSmoke = (input) => {
2454
+ var runSmoke = async (input) => {
2319
2455
  const baseline = readBaseline(input.root, input.name);
2320
- const { findings, outcomes } = smokeOver(input);
2321
- return compareToBaseline(input.name, baseline, outcomes, findings);
2456
+ const { candidate, findings, outcomes, work } = await smokeOver(input);
2457
+ return {
2458
+ ...compareToBaseline(input.name, baseline, outcomes, findings),
2459
+ ...candidate === void 0 ? {} : { candidate },
2460
+ ...existsSync7(work) ? { runCopy: work } : {}
2461
+ };
2322
2462
  };
2323
2463
  var CONSUMER_TREES_FILE = ".geonosis/consumer-trees.local.json";
2324
2464
  var localTrees = (root) => {
@@ -2356,28 +2496,31 @@ var dating = (root, name) => {
2356
2496
  if (!("sha" in now) || now.sha === commit.sha) return void 0;
2357
2497
  return { stale: `STALE ${name}: recorded at ${commit.sha}, the tree is at ${now.sha}` };
2358
2498
  };
2359
- var sweepSmoke = (input) => {
2499
+ var sweepSmoke = async (input) => {
2360
2500
  const absent = input.absent ?? [];
2361
2501
  if (input.skipMissing) {
2362
2502
  const owed = unrecordedButHere(input.root, input.wanted, absent);
2363
2503
  if (owed.length > 0) throw new CannotRun(owed.join("\n"));
2364
2504
  }
2365
- const entries = input.wanted.map((one) => {
2505
+ const entries = [];
2506
+ for (const one of input.wanted) {
2366
2507
  if (absent.includes(one.name)) {
2367
- return {
2508
+ entries.push({
2368
2509
  name: one.name,
2369
2510
  why: "--absent named it, so this release was not read against it"
2370
- };
2511
+ });
2512
+ continue;
2371
2513
  }
2372
2514
  if (input.skipMissing && !recordedAlready(input.root, one.name)) {
2373
- return {
2515
+ entries.push({
2374
2516
  name: one.name,
2375
2517
  why: `no snapshot of it here \u2014 record one with: geonosis-release smoke snapshot ${one.name} --from <their tree>`
2376
- };
2518
+ });
2519
+ continue;
2377
2520
  }
2378
2521
  const note = dating(input.root, one.name);
2379
- return {
2380
- comparison: runSmoke({
2522
+ entries.push({
2523
+ comparison: await runSmoke({
2381
2524
  ...one.commands === void 0 ? {} : { commands: one.commands },
2382
2525
  ...one.install === void 0 ? {} : { install: one.install },
2383
2526
  name: one.name,
@@ -2386,8 +2529,8 @@ var sweepSmoke = (input) => {
2386
2529
  }),
2387
2530
  name: one.name,
2388
2531
  ...note === void 0 ? {} : { note }
2389
- };
2390
- });
2532
+ });
2533
+ }
2391
2534
  return { entries };
2392
2535
  };
2393
2536
  var comparisonsIn = (sweep) => sweep.entries.flatMap((one) => "comparison" in one ? [one.comparison] : []);
@@ -2468,6 +2611,10 @@ var headline = (comparison) => {
2468
2611
  };
2469
2612
  var formatSmoke = (comparison) => [
2470
2613
  headline(comparison),
2614
+ ...comparison.candidate === void 0 ? [] : [` ${comparison.candidate}
2615
+ `],
2616
+ ...comparison.runCopy === void 0 ? [] : [` run copy: ${comparison.runCopy}
2617
+ `],
2471
2618
  ...comparison.outcomes.map(outcomeLine),
2472
2619
  ...comparison.broken.flatMap((one) => [
2473
2620
  ` ${one.phase} passed at the versions they are on and fails under this release:
@@ -2491,6 +2638,10 @@ var formatSmoke = (comparison) => [
2491
2638
  var formatBaseline = (name, baseline) => [
2492
2639
  `OK baseline ${name}: recorded against ${baseline.rc}
2493
2640
  `,
2641
+ ...baseline.candidate === void 0 ? [] : [` ${baseline.candidate}
2642
+ `],
2643
+ ...baseline.runCopy === void 0 ? [] : [` run copy: ${baseline.runCopy}
2644
+ `],
2494
2645
  ...baseline.phases.map(outcomeLine)
2495
2646
  ].join("");
2496
2647
  var SMOKE_TOOL = "release-smoke";
@@ -2519,8 +2670,8 @@ var smokeEnvelope = (comparison, durationMs) => {
2519
2670
  };
2520
2671
 
2521
2672
  // src/changelog.ts
2522
- import { existsSync as existsSync8, readdirSync as readdirSync6, readFileSync as readFileSync11 } from "fs";
2523
- import { join as join8, relative as relative5, resolve as resolve10, sep as sep4 } from "path";
2673
+ import { existsSync as existsSync8, readFileSync as readFileSync11 } from "fs";
2674
+ import { dirname as dirname3, join as join8, resolve as resolve10 } from "path";
2524
2675
  var CHANGELOG = "CHANGELOG.md";
2525
2676
  var VERSION_HEADING = /^##\s+v?(\d+\.\d+\.\d+.*)$/;
2526
2677
  var emptyHeadingsIn = (body, at) => {
@@ -2569,35 +2720,18 @@ var formatChangelogs = (report) => [
2569
2720
  ),
2570
2721
  report.empty.length === 0 ? `changelog PASS \u2014 ${report.versions} version heading(s) over ${report.read} file(s), every one of them says something` : `changelog FAIL \u2014 ${report.empty.length} of ${report.versions} version heading(s) over ${report.read} file(s) are empty`
2571
2722
  ].join("\n");
2572
- var NEVER_WALKED3 = /* @__PURE__ */ new Set(["build", "coverage", "dist", "node_modules", "storybook-static"]);
2573
2723
  var publishableDirs = (root) => {
2574
2724
  const found = [];
2575
- const walk = (dir) => {
2576
- let entries;
2725
+ for (const path of manifestsUnder(root)) {
2726
+ let manifest;
2577
2727
  try {
2578
- entries = readdirSync6(dir, { withFileTypes: true });
2728
+ manifest = JSON.parse(readFileSync11(path, "utf8"));
2579
2729
  } catch {
2580
- return;
2581
- }
2582
- for (const entry of entries) {
2583
- if (entry.isDirectory()) {
2584
- if (!entry.name.startsWith(".") && !NEVER_WALKED3.has(entry.name)) {
2585
- walk(join8(dir, entry.name));
2586
- }
2587
- continue;
2588
- }
2589
- if (entry.name !== "package.json") continue;
2590
- let manifest;
2591
- try {
2592
- manifest = JSON.parse(readFileSync11(join8(dir, entry.name), "utf8"));
2593
- } catch {
2594
- continue;
2595
- }
2596
- if (manifest.private === true || typeof manifest.name !== "string") continue;
2597
- found.push(relative5(root, dir).split(sep4).join("/"));
2730
+ continue;
2598
2731
  }
2599
- };
2600
- walk(root);
2732
+ if (manifest.private === true || typeof manifest.name !== "string") continue;
2733
+ found.push(pathOf(root, dirname3(path)));
2734
+ }
2601
2735
  return found.filter((one) => one !== "").toSorted();
2602
2736
  };
2603
2737
 
package/dist/index.d.ts CHANGED
@@ -197,6 +197,8 @@ type SmokeSnapshotConfig = {
197
197
  type SmokeConfig = {
198
198
  exclude?: string[];
199
199
  snapshots?: SmokeSnapshotConfig[];
200
+ /** How long an install or a phase may run before the runner kills its whole process group (#368). */
201
+ timeoutMs?: number;
200
202
  };
201
203
  /**
202
204
  * Law 6: every directory, dialect, secret name and worker is an OPTION. The defaults are empty, and
@@ -540,18 +542,24 @@ type Packed = {
540
542
  };
541
543
  type SmokeBaseline = {
542
544
  at: string;
545
+ /** The candidate's first step (#389) — absent when this run packed no `@geonosis/cli`. */
546
+ candidate?: string;
543
547
  packed: {
544
548
  name: string;
545
549
  version: string;
546
550
  }[];
547
551
  phases: PhaseOutcome[];
548
552
  rc: string;
553
+ /** Where a phase that failed left its copy — absent when every phase that ran passed. */
554
+ runCopy?: string;
549
555
  };
550
556
  type SmokeComparison = {
551
557
  broken: {
552
558
  lines: string[];
553
559
  phase: SmokePhase;
554
560
  }[];
561
+ /** The candidate's first step (#389) — absent when this run packed no `@geonosis/cli`. */
562
+ candidate?: string;
555
563
  findings: string[];
556
564
  known: SmokePhase[];
557
565
  mended: SmokePhase[];
@@ -562,6 +570,8 @@ type SmokeComparison = {
562
570
  phase: SmokePhase;
563
571
  why: string;
564
572
  }[];
573
+ /** Where a phase that failed left its copy — absent when every phase that ran passed. */
574
+ runCopy?: string;
565
575
  };
566
576
  declare const BASELINE_FILE = "baseline.json";
567
577
  /**
@@ -591,10 +601,10 @@ type RunInput = {
591
601
  rc?: string;
592
602
  root: string;
593
603
  };
594
- declare const recordBaseline: (input: RunInput) => SmokeBaseline;
604
+ declare const recordBaseline: (input: RunInput) => Promise<SmokeBaseline>;
595
605
  declare const readBaseline: (root: string, name: string) => SmokeBaseline;
596
606
  declare const compareToBaseline: (name: string, baseline: SmokeBaseline, outcomes: PhaseOutcome[], findings: string[]) => SmokeComparison;
597
- declare const runSmoke: (input: RunInput) => SmokeComparison;
607
+ declare const runSmoke: (input: RunInput) => Promise<SmokeComparison>;
598
608
  /**
599
609
  * What the sweep can say about WHEN a recording was taken: that its source has moved on, or that
600
610
  * the question cannot be asked of it at all.
@@ -649,7 +659,7 @@ declare const sweepSmoke: (input: {
649
659
  */
650
660
  skipMissing: boolean;
651
661
  wanted: WantedSnapshot[];
652
- }) => SmokeSweep;
662
+ }) => Promise<SmokeSweep>;
653
663
  declare const comparisonsIn: (sweep: SmokeSweep) => SmokeComparison[];
654
664
  declare const formatSweep: (sweep: SmokeSweep) => string;
655
665
  declare const sweepEnvelope: (sweep: SmokeSweep, durationMs: number) => ReportEnvelope;
package/dist/index.js CHANGED
@@ -74,7 +74,7 @@ import {
74
74
  statementsOf,
75
75
  sweepEnvelope,
76
76
  sweepSmoke
77
- } from "./chunk-NNGZ47I5.js";
77
+ } from "./chunk-RQOZEZSB.js";
78
78
  export {
79
79
  ADOPTION_NEXT,
80
80
  ADOPTION_TOOL,
@@ -40,11 +40,111 @@ import {
40
40
  sweepEnvelope,
41
41
  sweepSmoke,
42
42
  writeEnvelope
43
- } from "./chunk-NNGZ47I5.js";
43
+ } from "./chunk-RQOZEZSB.js";
44
44
 
45
45
  // src/release-cli.ts
46
46
  import { existsSync } from "fs";
47
- import { join, resolve } from "path";
47
+ import { join as join2, resolve } from "path";
48
+
49
+ // src/heavy-slot.ts
50
+ import { randomUUID } from "crypto";
51
+ import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
52
+ import { tmpdir } from "os";
53
+ import { dirname, join } from "path";
54
+ var HEAVY_SLOTS = 2;
55
+ var DEFAULT_WAIT_MS = 20 * 6e4;
56
+ var heavySlotDir = () => {
57
+ const named = process.env.GEONOSIS_HEAVY_SLOT_DIR;
58
+ if (named !== void 0 && named !== "") return named;
59
+ return join(tmpdir(), "geonosis-heavy");
60
+ };
61
+ var slotPath = (dir, at) => join(dir, `slot-${at}.json`);
62
+ var alive = (pid) => {
63
+ try {
64
+ process.kill(pid, 0);
65
+ return true;
66
+ } catch (error) {
67
+ return error.code === "EPERM";
68
+ }
69
+ };
70
+ var holderOf = (path) => {
71
+ try {
72
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
73
+ return typeof parsed.pid === "number" ? {
74
+ command: parsed.command ?? "unknown",
75
+ cwd: parsed.cwd ?? "somewhere",
76
+ pid: parsed.pid,
77
+ startedAt: parsed.startedAt ?? "unknown"
78
+ } : void 0;
79
+ } catch {
80
+ return void 0;
81
+ }
82
+ };
83
+ var reapIfDead = (path) => {
84
+ const holder = holderOf(path);
85
+ if (holder !== void 0 && !alive(holder.pid)) rmSync(path, { force: true });
86
+ };
87
+ var claim = (path, holder) => {
88
+ mkdirSync(dirname(path), { recursive: true });
89
+ const staging = `${path}.${holder.pid}.${randomUUID()}`;
90
+ try {
91
+ writeFileSync(staging, JSON.stringify(holder));
92
+ linkSync(staging, path);
93
+ return true;
94
+ } catch (error) {
95
+ if (error.code === "EEXIST") return false;
96
+ throw error;
97
+ } finally {
98
+ rmSync(staging, { force: true });
99
+ }
100
+ };
101
+ var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
102
+ var namedHolders = (dir) => Array.from({ length: HEAVY_SLOTS }, (_unused, at) => holderOf(slotPath(dir, at))).filter(
103
+ (holder) => holder !== void 0
104
+ );
105
+ var describeHolders = (holders) => holders.map((one) => `${one.pid} ${one.command} since ${one.startedAt}`).join(", ");
106
+ var HeavySlotTimeout = class extends Error {
107
+ };
108
+ var takeSlot = async ({
109
+ command,
110
+ cwd = process.cwd(),
111
+ dir = heavySlotDir(),
112
+ pollMs = 250,
113
+ say = (line) => process.stderr.write(`${line}
114
+ `),
115
+ waitMs = Number(process.env.GEONOSIS_HEAVY_WAIT_MS ?? "") || DEFAULT_WAIT_MS
116
+ }) => {
117
+ const until = Date.now() + waitMs;
118
+ let told = false;
119
+ for (; ; ) {
120
+ for (let at = 0; at < HEAVY_SLOTS; at += 1) {
121
+ const path = slotPath(dir, at);
122
+ reapIfDead(path);
123
+ const mine = {
124
+ command,
125
+ cwd,
126
+ pid: process.pid,
127
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
128
+ };
129
+ if (claim(path, mine)) {
130
+ return () => {
131
+ if (holderOf(path)?.pid === process.pid) rmSync(path, { force: true });
132
+ };
133
+ }
134
+ }
135
+ const holders = namedHolders(dir);
136
+ if (!told) {
137
+ told = true;
138
+ say(`waiting for a heavy slot: ${holders.length} held \u2014 ${describeHolders(holders)}`);
139
+ }
140
+ if (Date.now() >= until) {
141
+ throw new HeavySlotTimeout(
142
+ `waited ${waitMs} ms for a heavy slot: ${holders.length} held \u2014 ${describeHolders(holders)}`
143
+ );
144
+ }
145
+ await sleep(pollMs);
146
+ }
147
+ };
48
148
 
49
149
  // src/since.ts
50
150
  var MEANS = {
@@ -416,32 +516,37 @@ var wantedSnapshots = (parsed, root) => {
416
516
  var rcOf = (parsed, root, cwd) => {
417
517
  const named = parsed.read["--rc-dir"];
418
518
  if (named !== void 0) return resolve(cwd, named);
419
- if (existsSync(join(root, "package.json"))) return root;
519
+ if (existsSync(join2(root, "package.json"))) return root;
420
520
  throw new CannotRun(
421
521
  "nothing here is a release candidate \u2014 pass --rc-dir <the workspace whose packages this release publishes>, because a smoke with nothing packed compares a tree to itself"
422
522
  );
423
523
  };
424
- var baseline = (parsed, root, cwd) => {
524
+ var baseline = async (parsed, root, cwd) => {
425
525
  const wanted = wantedSnapshots(parsed, root);
426
526
  const one = wanted[0];
427
527
  if (parsed.read["--snapshot"] === void 0 || one === void 0) {
428
528
  throw new CannotRun("smoke baseline needs --snapshot <name>");
429
529
  }
430
- const rc = parsed.read["--rc-dir"];
431
- const recorded = recordBaseline({
432
- ...one.commands === void 0 ? {} : { commands: one.commands },
433
- ...one.install === void 0 ? {} : { install: one.install },
434
- name: one.name,
435
- ...rc === void 0 ? {} : { rc: resolve(cwd, rc) },
436
- root
437
- });
438
- process.stdout.write(
439
- parsed.json ? `${JSON.stringify(recorded, void 0, 2)}
530
+ const releaseSlot = await takeSlot({ command: "geonosis-release smoke baseline", cwd: root });
531
+ try {
532
+ const rc = parsed.read["--rc-dir"];
533
+ const recorded = await recordBaseline({
534
+ ...one.commands === void 0 ? {} : { commands: one.commands },
535
+ ...one.install === void 0 ? {} : { install: one.install },
536
+ name: one.name,
537
+ ...rc === void 0 ? {} : { rc: resolve(cwd, rc) },
538
+ root
539
+ });
540
+ process.stdout.write(
541
+ parsed.json ? `${JSON.stringify(recorded, void 0, 2)}
440
542
  ` : formatBaseline(one.name, recorded)
441
- );
442
- return 0;
543
+ );
544
+ return 0;
545
+ } finally {
546
+ releaseSlot();
547
+ }
443
548
  };
444
- var smokeRun = (parsed, root, cwd) => {
549
+ var smokeRun = async (parsed, root, cwd) => {
445
550
  const startedAt = Date.now();
446
551
  const wanted = wantedSnapshots(parsed, root);
447
552
  if (wanted.length === 0) {
@@ -449,13 +554,19 @@ var smokeRun = (parsed, root, cwd) => {
449
554
  "nothing here names a consumer to read this release against \u2014 geonosis.json \u2192 release.smoke.snapshots names them, or pass --snapshot <name>. A release check over zero consumers is not a green release check."
450
555
  );
451
556
  }
452
- const swept = sweepSmoke({
453
- absent: parsed.many["--absent"] ?? [],
454
- rc: rcOf(parsed, root, cwd),
455
- root,
456
- skipMissing: parsed.read["--snapshot"] === void 0,
457
- wanted
458
- });
557
+ const releaseSlot = await takeSlot({ command: "geonosis-release smoke run", cwd: root });
558
+ let swept;
559
+ try {
560
+ swept = await sweepSmoke({
561
+ absent: parsed.many["--absent"] ?? [],
562
+ rc: rcOf(parsed, root, cwd),
563
+ root,
564
+ skipMissing: parsed.read["--snapshot"] === void 0,
565
+ wanted
566
+ });
567
+ } finally {
568
+ releaseSlot();
569
+ }
459
570
  process.stdout.write(
460
571
  parsed.json ? `${JSON.stringify(swept, void 0, 2)}
461
572
  ` : formatSweep(swept)
@@ -481,7 +592,7 @@ var smokeRun = (parsed, root, cwd) => {
481
592
  }
482
593
  return comparisons.every((one) => one.ok) ? 0 : 1;
483
594
  };
484
- var smoke = (parsed, cwd) => {
595
+ var smoke = async (parsed, cwd) => {
485
596
  const root = rootOf(parsed, cwd);
486
597
  const verb = parsed.rest[0];
487
598
  if (verb === "snapshot") return snapshot(parsed, root);
@@ -507,6 +618,8 @@ geonosis.json \u2192 "release" every key optional; a repo that configured n
507
618
  that tree is installed with when the manager's default is not it,
508
619
  and { doctor?, lint?, typecheck? } when their gates are not the
509
620
  scripts of those names
621
+ timeoutMs number? how long an install or a phase may run before the runner kills its
622
+ whole process group (default 1200000 \u2014 20 minutes)
510
623
  secrets string[]? the secret names a deployment carries
511
624
  steps string[]? the release steps
512
625
  workers string[]? the workers it deploys
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/release",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "types": "./dist/index.d.ts",
5
5
  "description": "The release contract with its proofs: an expand-only migration gate over three dialects, a smoke that must name the version that answered it, and declared ≠ deployed.",
6
6
  "keywords": [
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "squawk-cli": "2.63.0",
49
- "@geonosis/ratchet": "2.5.0"
49
+ "@geonosis/ratchet": "2.5.1"
50
50
  },
51
51
  "engines": {
52
52
  "node": ">=22"