bulletin-deploy 0.7.26 → 0.7.27-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -207,9 +207,11 @@ if (!flags.help && !flags.version) {
207
207
  };
208
208
  process.on("uncaughtException", (e) => handleUnhandled(e, "uncaught"));
209
209
  process.on("unhandledRejection", (e) => handleUnhandled(e, "unhandled"));
210
- // POSIX exit codes: 128 + signal number. Matches shell conventions so
211
- // callers (e.g. consumer CI with EXIT_CODE_NO_RETRY=75) treat them as
212
- // retryable.
210
+ // POSIX exit codes: 128 + signal number. Signal exits (130/SIGINT,
211
+ // 143/SIGTERM, 129/SIGHUP) indicate cancellation or environment teardown
212
+ // and are retryable from the consumer's perspective. Contrast with
213
+ // NonRetryableError exits (code 78, POSIX EX_CONFIG) which callers must
214
+ // NOT retry — see EXIT_CODE_NO_RETRY in src/errors.ts.
213
215
  process.on("SIGINT", () => finalize("SIGINT", 130));
214
216
  process.on("SIGTERM", () => finalize("SIGTERM", 143));
215
217
  process.on("SIGHUP", () => finalize("SIGHUP", 129));
@@ -9,10 +9,10 @@ import {
9
9
  offerBugReport,
10
10
  scrubSecrets,
11
11
  setDeployContext
12
- } from "./chunk-5EWKYMDC.js";
13
- import "./chunk-QUQYXRTC.js";
14
- import "./chunk-MR6AV2UF.js";
15
- import "./chunk-5WKHSZT7.js";
12
+ } from "./chunk-EJUBFDXU.js";
13
+ import "./chunk-3AJST77I.js";
14
+ import "./chunk-P7XM4NJG.js";
15
+ import "./chunk-Y5EWXXFE.js";
16
16
  export {
17
17
  buildCliFlagsSummary,
18
18
  buildLabels,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-MR6AV2UF.js";
3
+ } from "./chunk-P7XM4NJG.js";
4
4
 
5
5
  // src/version-check.ts
6
6
  import { execSync, execFileSync } from "child_process";
@@ -2,11 +2,11 @@ import {
2
2
  classifyErrorArea,
3
3
  isInteractive,
4
4
  promptYesNo
5
- } from "./chunk-QUQYXRTC.js";
5
+ } from "./chunk-3AJST77I.js";
6
6
  import {
7
7
  VERSION,
8
8
  getCurrentSentryTraceId
9
- } from "./chunk-MR6AV2UF.js";
9
+ } from "./chunk-P7XM4NJG.js";
10
10
 
11
11
  // src/bug-report.ts
12
12
  import { execSync, execFileSync } from "child_process";
@@ -20,10 +20,10 @@ import {
20
20
  } from "./chunk-S7EM5VMW.js";
21
21
  import {
22
22
  setDeployContext
23
- } from "./chunk-5EWKYMDC.js";
23
+ } from "./chunk-EJUBFDXU.js";
24
24
  import {
25
25
  probeChunks
26
- } from "./chunk-6MDQDYVL.js";
26
+ } from "./chunk-KLXIR7NQ.js";
27
27
  import {
28
28
  packSection
29
29
  } from "./chunk-C2TS5MER.js";
@@ -34,7 +34,7 @@ import {
34
34
  parseDomainName,
35
35
  popStatusName,
36
36
  verifyNonceAdvanced
37
- } from "./chunk-X6YUEHEH.js";
37
+ } from "./chunk-MRRSSWRM.js";
38
38
  import {
39
39
  derivePoolAccounts,
40
40
  detectTestnet,
@@ -56,7 +56,7 @@ import {
56
56
  truncateAddress,
57
57
  withDeploySpan,
58
58
  withSpan
59
- } from "./chunk-MR6AV2UF.js";
59
+ } from "./chunk-P7XM4NJG.js";
60
60
  import {
61
61
  DEFAULT_ENV_ID,
62
62
  getPopSelfServeConfig,
@@ -137,7 +137,10 @@ var CHUNK_SIZE = 2 * 1024 * 1024;
137
137
  var MAX_FILE_SIZE = 8 * 1024 * 1024;
138
138
  var MAX_RECONNECTIONS = parseInt(process.env.BULLETIN_MAX_RECONNECTIONS ?? "3", 10);
139
139
  var CHUNK_TIMEOUT_MS = parseInt(process.env.BULLETIN_CHUNK_TIMEOUT_MS ?? "180000", 10);
140
- var CHUNK_MORTALITY_PERIOD = 16;
140
+ var CHUNK_MORTALITY_PERIOD = (() => {
141
+ const v = parseInt(process.env.BULLETIN_CHUNK_MORTALITY_PERIOD ?? "", 10);
142
+ return Number.isFinite(v) && v > 0 ? v : 16;
143
+ })();
141
144
  var RETRY_BASE_DELAY_MS = 2e3;
142
145
  var RETRY_MAX_DELAY_MS = 15e3;
143
146
  var WS_HEARTBEAT_TIMEOUT_MS = 3e5;
@@ -663,6 +666,10 @@ async function storeChunkedContent(chunks, { client: existingClient, unsafeApi:
663
666
  continue;
664
667
  }
665
668
  captureWarning("Chunk upload failed, retrying", { chunkIndex: fail.index + 1, maxRetries: MAX_CHUNK_RETRIES, error: fail.error?.message?.slice(0, 200) });
669
+ const isExpiryFailure = fail.error?.message?.includes("isValid:false");
670
+ if (isExpiryFailure) {
671
+ console.log(` Chunk ${fail.index + 1}: tx rejected (isValid:false), likely mortal era expiry \u2014 reissuing with fresh nonce`);
672
+ }
666
673
  let retried = false;
667
674
  for (let attempt = 1; attempt <= MAX_CHUNK_RETRIES; attempt++) {
668
675
  recordRecoveryAndCheckBudget("chunk_retry");
@@ -1050,6 +1057,11 @@ function preWarmGateway(chunkCids, gateways) {
1050
1057
  }
1051
1058
  }
1052
1059
  }
1060
+ function applyManifestFetchAttributes(fetched) {
1061
+ setDeployAttribute("deploy.manifest.fetch_source", fetched.source);
1062
+ setDeployAttribute("deploy.manifest.fetch_attempts", String(fetched.attempts ?? 0));
1063
+ setDeployAttribute("deploy.manifest.bytes_downloaded", String(fetched.bytesDownloaded ?? 0));
1064
+ }
1053
1065
  async function storeDirectoryV2(directoryPath, opts = {}) {
1054
1066
  if (opts.password) return storeDirectory(directoryPath, opts);
1055
1067
  const provider = opts.provider ?? {};
@@ -1063,6 +1075,7 @@ async function storeDirectoryV2(directoryPath, opts = {}) {
1063
1075
  });
1064
1076
  const prevManifest = fetched.source === "embedded" ? fetched.manifest : null;
1065
1077
  console.log(` Manifest fetch: ${fetched.source}${fetched.source !== "none" ? ` (${fetched.attempts} attempt${fetched.attempts === 1 ? "" : "s"})` : ""}`);
1078
+ applyManifestFetchAttributes(fetched);
1066
1079
  const deployedAt = opts.reproducibleSource ? resolveReproducibleTimestamp(opts.reproducibleSource) : (/* @__PURE__ */ new Date()).toISOString();
1067
1080
  writeEmbeddedManifestPlaceholder(directoryPath, {
1068
1081
  version: MANIFEST_VERSION,
@@ -1462,6 +1475,13 @@ async function estimateUploadBytes(content) {
1462
1475
  return null;
1463
1476
  }
1464
1477
  }
1478
+ function assertSubdomainOwnerMatchesSigner(result, signerEvmAddress, sublabel, parentLabel) {
1479
+ if (result.owned && result.owner?.toLowerCase() !== signerEvmAddress?.toLowerCase()) {
1480
+ throw new NonRetryableError(
1481
+ `Subdomain ${sublabel}.${parentLabel}.dot is already owned by ${result.owner} (signer is ${signerEvmAddress}). Use a fresh subdomain label, or release the existing registration.`
1482
+ );
1483
+ }
1484
+ }
1465
1485
  async function deploy(content, domainName = null, options = {}) {
1466
1486
  const envId = options.env ?? DEFAULT_ENV_ID;
1467
1487
  let envBulletin = [DEFAULT_BULLETIN_RPC];
@@ -1545,8 +1565,9 @@ async function deploy(content, domainName = null, options = {}) {
1545
1565
  await preflight.connect(resolveDotnsConnectOptions(options, envAssetHub, envAutoAccountMapping, envContracts, envNativeToEthRatio, envId, envPopSelfServe, envRegisterStorageDeposit));
1546
1566
  if (parsed?.isSubdomain) {
1547
1567
  try {
1548
- const { owned: subOwned } = await preflight.checkSubdomainOwnership(parsed.sublabel, parsed.parentLabel);
1549
- if (!subOwned) {
1568
+ const subResult = await preflight.checkSubdomainOwnership(parsed.sublabel, parsed.parentLabel);
1569
+ assertSubdomainOwnerMatchesSigner(subResult, preflight.evmAddress, parsed.sublabel, parsed.parentLabel);
1570
+ if (!subResult.owned) {
1550
1571
  const { owned: parentOwned, owner: parentOwner } = await preflight.checkOwnership(parsed.parentLabel);
1551
1572
  if (!parentOwned) {
1552
1573
  throw new NonRetryableError(
@@ -2280,8 +2301,10 @@ export {
2280
2301
  detectFramework,
2281
2302
  checkDeploySize,
2282
2303
  resolveReproducibleTimestamp,
2304
+ applyManifestFetchAttributes,
2283
2305
  storeDirectoryV2,
2284
2306
  resolveDotnsConnectOptions,
2285
2307
  estimateUploadBytes,
2308
+ assertSubdomainOwnerMatchesSigner,
2286
2309
  deploy
2287
2310
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  captureWarning
3
- } from "./chunk-MR6AV2UF.js";
3
+ } from "./chunk-P7XM4NJG.js";
4
4
 
5
5
  // src/chunk-probe.ts
6
6
  import { Twox128, Blake2128Concat, decAnyMetadata, unifyMetadata } from "@polkadot-api/substrate-bindings";
@@ -7,10 +7,13 @@ import {
7
7
  setDeploySentryTag,
8
8
  truncateAddress,
9
9
  withSpan
10
- } from "./chunk-MR6AV2UF.js";
10
+ } from "./chunk-P7XM4NJG.js";
11
11
  import {
12
12
  validateContractAddresses
13
13
  } from "./chunk-MGU5I7H5.js";
14
+ import {
15
+ NonRetryableError
16
+ } from "./chunk-ZOC4GITL.js";
14
17
 
15
18
  // src/dotns.ts
16
19
  import crypto from "crypto";
@@ -357,7 +360,7 @@ function sanitizeDomainLabel(label) {
357
360
  }
358
361
  return label;
359
362
  }
360
- function validateDomainLabel(label) {
363
+ function validateDomainLabel(label, opts = {}) {
361
364
  if (!/^[a-z0-9-]{3,}$/.test(label)) throw new Error("Invalid domain label: must contain only lowercase letters, digits, and hyphens, min 3 chars");
362
365
  if (label.startsWith("-") || label.endsWith("-")) throw new Error("Invalid domain label: cannot start or end with hyphen");
363
366
  const sanitized = sanitizeDomainLabel(label);
@@ -369,6 +372,15 @@ function validateDomainLabel(label) {
369
372
  `Invalid domain label: "${sanitized}" \u2014 dotns base-name extraction leaves a trailing hyphen ("${baseWithHyphen}"), which the registry rejects with PopError("Name must be lowercase ASCII DNS label"). Drop the hyphen before the digits (e.g. "${dropHyphen}") or add a non-digit segment between (e.g. "${insertSegment}").`
370
373
  );
371
374
  }
375
+ if (opts.checkReserved !== false) {
376
+ const classification = classifyDotnsLabel(sanitized);
377
+ if (classification.status === ProofOfPersonhoodStatus.Reserved) {
378
+ const sanitizeTrail = label !== sanitized ? `Input "${label}" was sanitized to "${sanitized}" (excess trailing digits trimmed). ` : "";
379
+ throw new NonRetryableError(
380
+ `${sanitizeTrail}Invalid domain label "${sanitized}": ${classification.message}`
381
+ );
382
+ }
383
+ }
372
384
  return sanitized;
373
385
  }
374
386
  function isCommitmentMature(chainNowSeconds, commitTimestampSeconds, minimumAgeSeconds) {
@@ -411,7 +423,7 @@ function canRegister(requiredStatus, userStatus, trailingDigits) {
411
423
  return trailingDigits !== 0 && userStatus !== ProofOfPersonhoodStatus.ProofOfPersonhoodLite;
412
424
  }
413
425
  function exampleNoStatusLabel(label) {
414
- const base = stripTrailingDigits(validateDomainLabel(label)).replace(/[^a-z0-9-]/g, "x");
426
+ const base = stripTrailingDigits(validateDomainLabel(label, { checkReserved: false })).replace(/[^a-z0-9-]/g, "x");
415
427
  return `${base.padEnd(9, "x").slice(0, 9)}00.dot`;
416
428
  }
417
429
  function parseDomainName(input) {
@@ -422,7 +434,7 @@ function parseDomainName(input) {
422
434
  return { isSubdomain: false, label: sanitized, sublabel: null, parentLabel: null, fullName: `${sanitized}.dot` };
423
435
  }
424
436
  if (parts.length === 2) {
425
- const sanitizedSub = validateDomainLabel(parts[0]);
437
+ const sanitizedSub = validateDomainLabel(parts[0], { checkReserved: false });
426
438
  const sanitizedParent = validateDomainLabel(parts[1]);
427
439
  const fullLabel = `${sanitizedSub}.${sanitizedParent}`;
428
440
  return { isSubdomain: true, label: fullLabel, sublabel: sanitizedSub, parentLabel: sanitizedParent, fullName: `${fullLabel}.dot` };
@@ -1137,6 +1149,41 @@ var DotNS = class {
1137
1149
  }
1138
1150
  return decodeFunctionResult({ abi: contractAbi, functionName, data: callResult.result.value.data });
1139
1151
  }
1152
+ /**
1153
+ * Like contractCall, but returns null when the chain replies with empty data
1154
+ * ("0x"). Use this for view functions where an unset storage slot is a
1155
+ * meaningful answer (e.g. resolver(node) for a name with no resolver,
1156
+ * text records, optional ownership lookups). Use the strict contractCall
1157
+ * for read paths that must always return a value.
1158
+ */
1159
+ async contractCallNullable(contractAddress, contractAbi, functionName, args = []) {
1160
+ this.ensureConnected();
1161
+ if (!this.clientWrapper) throw new Error("contractCallNullable: polkadot-api client not available");
1162
+ const encodedCallData = encodeFunctionData({ abi: contractAbi, functionName, args });
1163
+ const callResult = await this.clientWrapper.performDryRunCall(this.substrateAddress, contractAddress, 0n, encodedCallData);
1164
+ if (!callResult.result.isOk) {
1165
+ const errorData = callResult.result.value;
1166
+ throw new Error(formatContractDryRunFailure({
1167
+ revertData: errorData?.data ?? "0x",
1168
+ revertFlags: errorData?.flags ?? 0n,
1169
+ gasConsumed: callResult.gasConsumed,
1170
+ gasRequired: callResult.gasRequired,
1171
+ storageDeposit: callResult.storageDeposit?.value
1172
+ }, {
1173
+ contractAddress,
1174
+ functionName,
1175
+ signerSubstrateAddress: this.substrateAddress,
1176
+ signerEvmAddress: this.evmAddress ?? void 0,
1177
+ value: 0n,
1178
+ encodedData: encodedCallData,
1179
+ args,
1180
+ contracts: this._contracts
1181
+ }));
1182
+ }
1183
+ const rawData = callResult.result.value.data ?? "0x";
1184
+ if (rawData === "0x" || rawData === "" || rawData.length <= 2) return null;
1185
+ return decodeFunctionResult({ abi: contractAbi, functionName, data: rawData });
1186
+ }
1140
1187
  async contractTransaction(contractAddress, value, contractAbi, functionName, args = [], statusCallback = () => {
1141
1188
  }, { useNoncePolling, verifyEffect } = {}) {
1142
1189
  this.ensureConnected();
@@ -1154,7 +1201,8 @@ var DotNS = class {
1154
1201
  const checkAddress = (ownerAddress || this.evmAddress).toLowerCase();
1155
1202
  const tokenId = computeDomainTokenId(label);
1156
1203
  try {
1157
- const owner = await withTimeout(this.contractCall(this._contracts.DOTNS_REGISTRAR, DOTNS_REGISTRAR_ABI, "ownerOf", [tokenId]), 3e4, "ownerOf");
1204
+ const owner = await withTimeout(this.contractCallNullable(this._contracts.DOTNS_REGISTRAR, DOTNS_REGISTRAR_ABI, "ownerOf", [tokenId]), 3e4, "ownerOf");
1205
+ if (owner === null) return { owned: false, owner: null };
1158
1206
  const owned = owner.toLowerCase() === checkAddress;
1159
1207
  return { owned, owner };
1160
1208
  } catch {
@@ -1187,7 +1235,7 @@ var DotNS = class {
1187
1235
  if (!this.clientWrapper) return { owned: false, owner: null };
1188
1236
  const node = namehash(`${sublabel}.${parentLabel}.dot`);
1189
1237
  try {
1190
- const owner = await withTimeout(this.contractCall(this._contracts.DOTNS_REGISTRY, DOTNS_REGISTRY_ABI, "owner", [node]), 3e4, "owner");
1238
+ const owner = await withTimeout(this.contractCallNullable(this._contracts.DOTNS_REGISTRY, DOTNS_REGISTRY_ABI, "owner", [node]), 3e4, "owner");
1191
1239
  if (!owner || owner === zeroAddress) return { owned: false, owner: null };
1192
1240
  const owned = owner.toLowerCase() === this.evmAddress.toLowerCase();
1193
1241
  return { owned, owner };
@@ -1310,7 +1358,7 @@ var DotNS = class {
1310
1358
  let onChainValue = "";
1311
1359
  let lastPrintedElapsed = -1;
1312
1360
  while (true) {
1313
- const onChain = await withTimeout(this.contractCall(this._contracts.DOTNS_RESOLVER, DOTNS_TEXT_RESOLVER_ABI, "text", [node, key]), 3e4, "text");
1361
+ const onChain = await withTimeout(this.contractCallNullable(this._contracts.DOTNS_RESOLVER, DOTNS_TEXT_RESOLVER_ABI, "text", [node, key]), 3e4, "text");
1314
1362
  onChainValue = onChain ?? "";
1315
1363
  if (onChainValue === value) break;
1316
1364
  const nowChainMs = Number(await this.clientWrapper.client.query.Timestamp.Now.getValue());
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  package_default,
3
3
  writeRunState
4
- } from "./chunk-5WKHSZT7.js";
4
+ } from "./chunk-Y5EWXXFE.js";
5
5
 
6
6
  // src/memory-report.ts
7
7
  import * as fs2 from "fs";
@@ -238,6 +238,14 @@ function getDeployAttributes(domain) {
238
238
  "deploy.pool.nonce_collision_reupload_count": 0,
239
239
  // Manifest-aware Phase A trust: count of section-1 CIDs trusted from prev manifest.
240
240
  "deploy.phase_a.chunks_trusted": 0,
241
+ // Manifest fetch outcome. Seeded so every span carries the attributes even when
242
+ // fetchPreviousManifest is never reached (first deploy, early error, non-incremental
243
+ // path). "none" + "0" form the denominator for ratio queries:
244
+ // count_if(deploy.manifest.fetch_source, "heuristic_fallback") / count().
245
+ // Both string-valued per @sentry/node EAP numeric-attribute caveat.
246
+ "deploy.manifest.fetch_source": "none",
247
+ "deploy.manifest.fetch_attempts": "0",
248
+ "deploy.manifest.bytes_downloaded": "0",
241
249
  // Bulletin storage upload chain receipt (root-node tx, or last chunk when root skipped).
242
250
  // Empty-string default so every span carries the attribute for filter queries.
243
251
  "bulletin.upload.tx_hash": "",
@@ -288,16 +296,49 @@ function computeDeployOutcome(errorCategory, isSad, sadReason) {
288
296
  if (isSad) return `sad_${sadReason}`;
289
297
  return "clean";
290
298
  }
299
+ var ERROR_KIND_RULES = [
300
+ [/Contract reverted|Contract execution would revert|revert(?:ed|ing)?\s*\(flags=[0-9]+\)/i, "contract-revert"],
301
+ [/timed out after \d+s waiting for block|Transaction not included after \d+s|Transaction did not settle within/i, "chain-timeout"],
302
+ [/\bstale\b.*nonce|nonce.*\bstale\b|"type"\s*:\s*"Future"|Invalid::Future|tx rejected by pool/i, "nonce-stale"],
303
+ [/heartbeat timeout|WS halt|Unable to connect|ChainHead disjointed|websocket.*closed|socket closed|disconnect/i, "connection"],
304
+ [/requires ProofOfPersonhoodFull,\s*but this signer is NoStatus/i, "naming.pop_required"],
305
+ [/Domain\s+\S+\.dot\s+is already owned by\s+0x[a-fA-F0-9]+/i, "naming.already_owned"],
306
+ [/Cannot deploy\s+[\w.-]+\.dot:\s*parent\s+[\w.-]+\.dot\s+is owned by/i, "naming.subdomain_orphan"],
307
+ [/Post-deploy verification failed for .+: on-chain contenthash is /i, "verify.contenthash_mismatch"],
308
+ [/Deploy verification failed:\s*DAG-PB root.+not finalised/i, "verify.dagpb_not_finalised"],
309
+ [/Retry budget exhausted:.*recovery attempts/i, "network.recovery_exhausted"],
310
+ [/ReviveApi\.\w+ timed out after \d+ms/i, "chain.api_timeout"],
311
+ [/^INVARIANT FAILED:/i, "tool.invariant"]
312
+ ];
291
313
  function classifyErrorKind(msg) {
292
- if (/Contract reverted|Contract execution would revert|revert(?:ed|ing)?\s*\(flags=[0-9]+\)/i.test(msg)) return "contract-revert";
293
- if (/timed out after \d+s waiting for block|Transaction not included after \d+s|Transaction did not settle within/i.test(msg)) return "chain-timeout";
294
- if (/\bstale\b.*nonce|nonce.*\bstale\b|"type"\s*:\s*"Future"|Invalid::Future|tx rejected by pool/i.test(msg)) return "nonce-stale";
295
- if (/heartbeat timeout|WS halt|Unable to connect|ChainHead disjointed|websocket.*closed|socket closed|disconnect/i.test(msg)) return "connection";
314
+ for (const [re, kind] of ERROR_KIND_RULES) {
315
+ if (re.test(msg)) return kind;
316
+ }
296
317
  return "unknown";
297
318
  }
298
319
  function sanitizeErrorMessage(msg) {
299
320
  return scrubPaths(msg.slice(0, 500));
300
321
  }
322
+ function analyseErrorPattern(msg) {
323
+ const tags = [];
324
+ const len = msg.length;
325
+ if (len < 50) tags.push("len:lt50");
326
+ else if (len < 100) tags.push("len:50-99");
327
+ else if (len < 200) tags.push("len:100-199");
328
+ else if (len < 500) tags.push("len:200-499");
329
+ else tags.push("len:gte500");
330
+ if (/[a-z]+:\/\/[^\s:/?#]+:[^\s@/?#]+@/i.test(msg)) tags.push("url-userinfo");
331
+ const longHexRuns = (msg.match(/[0-9a-fA-F]{40,}/g) ?? []).filter((h) => !h.toLowerCase().startsWith("e30") && h.length !== 40);
332
+ if (longHexRuns.length > 0) tags.push(`long-hex:${Math.min(longHexRuns.length, 9)}`);
333
+ const b64ish = msg.match(/[A-Za-z0-9+/=]{30,}/g) ?? [];
334
+ if (b64ish.some((s) => /[A-Z]/.test(s) && /[a-z]/.test(s) && /[0-9]/.test(s))) tags.push("base64ish");
335
+ if (/\b[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/.test(msg)) tags.push("jwt-shape");
336
+ const evmCount = (msg.match(/0x[a-fA-F0-9]{40}\b/g) ?? []).length;
337
+ if (evmCount > 0) tags.push(`evm:${Math.min(evmCount, 9)}`);
338
+ if (/\b[1-9A-HJ-NP-Za-km-z]{46,49}\b/.test(msg)) tags.push("ss58-shape");
339
+ if (/(?:\b[a-z]{3,8}\s){11,23}\b[a-z]{3,8}\b/.test(msg)) tags.push("mnemonic-shape");
340
+ return tags.join(",");
341
+ }
301
342
  async function withSpan(op, description, attributes, fn) {
302
343
  if (!Sentry) return fn();
303
344
  return Sentry.startSpan({ op, name: description, attributes }, async (span) => {
@@ -308,6 +349,7 @@ async function withSpan(op, description, attributes, fn) {
308
349
  span.setAttribute("error.message", msg);
309
350
  span.setAttribute("deploy.error_kind", classifyErrorKind(msg));
310
351
  span.setAttribute("deploy.error_message", sanitizeErrorMessage(msg));
352
+ span.setAttribute("deploy.error_pattern_signature", analyseErrorPattern(msg));
311
353
  span.setStatus({ code: 2, message: "internal_error" });
312
354
  throw error;
313
355
  }
@@ -407,6 +449,7 @@ async function withDeploySpan(domain, fn) {
407
449
  span.setAttribute("deploy.error_category", errorCategory);
408
450
  span.setAttribute("deploy.error_kind", classifyErrorKind(msg));
409
451
  span.setAttribute("deploy.error_message", sanitizeErrorMessage(msg));
452
+ span.setAttribute("deploy.error_pattern_signature", analyseErrorPattern(msg));
410
453
  currentErrorCategory = errorCategory;
411
454
  const isExpected = isExpectedError(msg);
412
455
  span.setAttribute("deploy.expected", isExpected ? "true" : "false");
@@ -667,6 +710,7 @@ export {
667
710
  computeDeployOutcome,
668
711
  classifyErrorKind,
669
712
  sanitizeErrorMessage,
713
+ analyseErrorPattern,
670
714
  withSpan,
671
715
  sampleMemory,
672
716
  withDeploySpan,
@@ -6,7 +6,7 @@ import * as path from "path";
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "bulletin-deploy",
9
- version: "0.7.26",
9
+ version: "0.7.27-rc.2",
10
10
  private: false,
11
11
  repository: {
12
12
  type: "git",
@@ -5,9 +5,9 @@ import {
5
5
  _decodeStorageValue,
6
6
  _resetProbeSession,
7
7
  probeChunks
8
- } from "./chunk-6MDQDYVL.js";
9
- import "./chunk-MR6AV2UF.js";
10
- import "./chunk-5WKHSZT7.js";
8
+ } from "./chunk-KLXIR7NQ.js";
9
+ import "./chunk-P7XM4NJG.js";
10
+ import "./chunk-Y5EWXXFE.js";
11
11
  export {
12
12
  ChainProbeCrossValidationError,
13
13
  ChainProbeMetadataError,
package/dist/deploy.d.ts CHANGED
@@ -60,7 +60,7 @@ interface StoredChunk {
60
60
  declare const DEFAULT_BULLETIN_RPC = "wss://paseo-bulletin-rpc.polkadot.io";
61
61
  declare const DEFAULT_POOL_SIZE = 10;
62
62
  declare function setWsHaltCallback(cb: (() => void) | null): void;
63
- declare const CHUNK_MORTALITY_PERIOD = 16;
63
+ declare const CHUNK_MORTALITY_PERIOD: number;
64
64
  declare function retryBudgetExhausted(history: number[], maxEvents: number, windowMs: number, now?: number): boolean;
65
65
  declare function isConnectionError(error: any): boolean;
66
66
  declare function deriveRootSigner(mnemonic: string, path?: string): {
@@ -170,6 +170,11 @@ declare function checkDeploySize(carBytes: number, opts: {
170
170
  allowLargeDeploy?: boolean;
171
171
  }): SizeDecision;
172
172
  declare function resolveReproducibleTimestamp(source: string): string;
173
+ declare function applyManifestFetchAttributes(fetched: {
174
+ source: string;
175
+ attempts?: number;
176
+ bytesDownloaded?: number;
177
+ }): void;
173
178
  declare function storeDirectoryV2(directoryPath: string, opts?: StoreDirectoryOptions): Promise<{
174
179
  storageCid: string;
175
180
  ipfsCid: string;
@@ -262,6 +267,15 @@ declare function resolveDotnsConnectOptions(options: Pick<DeployOptions, "mnemon
262
267
  registerStorageDeposit?: bigint;
263
268
  };
264
269
  declare function estimateUploadBytes(content: DeployContent): Promise<number | null>;
270
+ /**
271
+ * Throws NonRetryableError if a subdomain is owned by a different address
272
+ * than the current signer. Called in the preflight branch before chunk upload.
273
+ * Issue #562: preflight was only checking `owned`, not comparing `owner`.
274
+ */
275
+ declare function assertSubdomainOwnerMatchesSigner(result: {
276
+ owned: boolean;
277
+ owner: string | null | undefined;
278
+ }, signerEvmAddress: string | null | undefined, sublabel: string, parentLabel: string): void;
265
279
  declare function deploy(content: DeployContent, domainName?: string | null, options?: DeployOptions): Promise<DeployResult>;
266
280
 
267
- export { CHUNK_MORTALITY_PERIOD, DEFAULT_BULLETIN_RPC, DEFAULT_POOL_SIZE, type DeployContent, type DeployOptions, type DeployResult, ENCRYPT_KEY_LEN, ENCRYPT_MAGIC, ENCRYPT_NONCE_LEN, ENCRYPT_PBKDF2_ITERATIONS, ENCRYPT_SALT_LEN, ENCRYPT_TAG_LEN, type SizeDecision, type StoreDirectoryOptions, __assignDenseNoncesForTest, buildFilesMap, checkDeploySize, chunk, computeStorageCid, createCID, deploy, deriveRootSigner, detectFramework, encodeContenthash, encryptContent, estimateUploadBytes, friendlyChainError, hasIPFS, isConnectionError, merkleize, resolveDotnsConnectOptions, resolveReproducibleTimestamp, retryBudgetExhausted, setWsHaltCallback, storeChunkedContent, storeDirectory, storeDirectoryV2, storeFile };
281
+ export { CHUNK_MORTALITY_PERIOD, DEFAULT_BULLETIN_RPC, DEFAULT_POOL_SIZE, type DeployContent, type DeployOptions, type DeployResult, ENCRYPT_KEY_LEN, ENCRYPT_MAGIC, ENCRYPT_NONCE_LEN, ENCRYPT_PBKDF2_ITERATIONS, ENCRYPT_SALT_LEN, ENCRYPT_TAG_LEN, type SizeDecision, type StoreDirectoryOptions, __assignDenseNoncesForTest, applyManifestFetchAttributes, assertSubdomainOwnerMatchesSigner, buildFilesMap, checkDeploySize, chunk, computeStorageCid, createCID, deploy, deriveRootSigner, detectFramework, encodeContenthash, encryptContent, estimateUploadBytes, friendlyChainError, hasIPFS, isConnectionError, merkleize, resolveDotnsConnectOptions, resolveReproducibleTimestamp, retryBudgetExhausted, setWsHaltCallback, storeChunkedContent, storeDirectory, storeDirectoryV2, storeFile };
package/dist/deploy.js CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  ENCRYPT_SALT_LEN,
10
10
  ENCRYPT_TAG_LEN,
11
11
  __assignDenseNoncesForTest,
12
+ applyManifestFetchAttributes,
13
+ assertSubdomainOwnerMatchesSigner,
12
14
  buildFilesMap,
13
15
  checkDeploySize,
14
16
  chunk,
@@ -32,19 +34,19 @@ import {
32
34
  storeDirectory,
33
35
  storeDirectoryV2,
34
36
  storeFile
35
- } from "./chunk-NEYQ2JMY.js";
37
+ } from "./chunk-JHD3OP3E.js";
36
38
  import "./chunk-KHVTYIIX.js";
37
39
  import "./chunk-KOSF5FDO.js";
38
40
  import "./chunk-FZWJV5AD.js";
39
41
  import "./chunk-S7EM5VMW.js";
40
- import "./chunk-5EWKYMDC.js";
41
- import "./chunk-QUQYXRTC.js";
42
- import "./chunk-6MDQDYVL.js";
42
+ import "./chunk-EJUBFDXU.js";
43
+ import "./chunk-3AJST77I.js";
44
+ import "./chunk-KLXIR7NQ.js";
43
45
  import "./chunk-C2TS5MER.js";
44
- import "./chunk-X6YUEHEH.js";
46
+ import "./chunk-MRRSSWRM.js";
45
47
  import "./chunk-QMYW3D6E.js";
46
- import "./chunk-MR6AV2UF.js";
47
- import "./chunk-5WKHSZT7.js";
48
+ import "./chunk-P7XM4NJG.js";
49
+ import "./chunk-Y5EWXXFE.js";
48
50
  import "./chunk-MGU5I7H5.js";
49
51
  import {
50
52
  EXIT_CODE_NO_RETRY,
@@ -64,6 +66,8 @@ export {
64
66
  EXIT_CODE_NO_RETRY,
65
67
  NonRetryableError,
66
68
  __assignDenseNoncesForTest,
69
+ applyManifestFetchAttributes,
70
+ assertSubdomainOwnerMatchesSigner,
67
71
  buildFilesMap,
68
72
  checkDeploySize,
69
73
  chunk,
package/dist/dotns.d.ts CHANGED
@@ -159,7 +159,9 @@ declare function computeDomainTokenId(label: string): bigint;
159
159
  declare function countTrailingDigits(label: string): number;
160
160
  declare function stripTrailingDigits(label: string): string;
161
161
  declare function sanitizeDomainLabel(label: string): string;
162
- declare function validateDomainLabel(label: string): string;
162
+ declare function validateDomainLabel(label: string, opts?: {
163
+ checkReserved?: boolean;
164
+ }): string;
163
165
  declare function isCommitmentMature(chainNowSeconds: number, commitTimestampSeconds: number, minimumAgeSeconds: number): boolean;
164
166
  declare function isCommitmentTimingBarerevert(msg: string): boolean;
165
167
  declare function classifyDotnsLabel(label: string): {
@@ -301,6 +303,14 @@ declare class DotNS {
301
303
  } | null>;
302
304
  private submitTransfer;
303
305
  contractCall(contractAddress: string, contractAbi: readonly any[], functionName: string, args?: any[]): Promise<any>;
306
+ /**
307
+ * Like contractCall, but returns null when the chain replies with empty data
308
+ * ("0x"). Use this for view functions where an unset storage slot is a
309
+ * meaningful answer (e.g. resolver(node) for a name with no resolver,
310
+ * text records, optional ownership lookups). Use the strict contractCall
311
+ * for read paths that must always return a value.
312
+ */
313
+ contractCallNullable(contractAddress: string, contractAbi: readonly any[], functionName: string, args?: any[]): Promise<any | null>;
304
314
  contractTransaction(contractAddress: string, value: bigint, contractAbi: readonly any[], functionName: string, args?: any[], statusCallback?: (status: string) => void, { useNoncePolling, verifyEffect }?: {
305
315
  useNoncePolling?: boolean;
306
316
  verifyEffect?: () => Promise<boolean>;
package/dist/dotns.js CHANGED
@@ -41,10 +41,10 @@ import {
41
41
  stripTrailingDigits,
42
42
  validateDomainLabel,
43
43
  verifyNonceAdvanced
44
- } from "./chunk-X6YUEHEH.js";
44
+ } from "./chunk-MRRSSWRM.js";
45
45
  import "./chunk-QMYW3D6E.js";
46
- import "./chunk-MR6AV2UF.js";
47
- import "./chunk-5WKHSZT7.js";
46
+ import "./chunk-P7XM4NJG.js";
47
+ import "./chunk-Y5EWXXFE.js";
48
48
  import "./chunk-MGU5I7H5.js";
49
49
  import "./chunk-ZOC4GITL.js";
50
50
  export {
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  deploy,
3
3
  merkleizeJS,
4
4
  merkleizeWithStableOrder
5
- } from "./chunk-NEYQ2JMY.js";
5
+ } from "./chunk-JHD3OP3E.js";
6
6
  import {
7
7
  computeStats,
8
8
  renderSummary,
@@ -24,18 +24,18 @@ import {
24
24
  isVolatilePath,
25
25
  parseManifest
26
26
  } from "./chunk-S7EM5VMW.js";
27
- import "./chunk-5EWKYMDC.js";
28
- import "./chunk-QUQYXRTC.js";
27
+ import "./chunk-EJUBFDXU.js";
28
+ import "./chunk-3AJST77I.js";
29
29
  import {
30
30
  probeChunks
31
- } from "./chunk-6MDQDYVL.js";
31
+ } from "./chunk-KLXIR7NQ.js";
32
32
  import "./chunk-C2TS5MER.js";
33
33
  import {
34
34
  DEFAULT_MNEMONIC,
35
35
  DotNS,
36
36
  parseDomainName,
37
37
  sanitizeDomainLabel
38
- } from "./chunk-X6YUEHEH.js";
38
+ } from "./chunk-MRRSSWRM.js";
39
39
  import {
40
40
  bootstrapPool,
41
41
  derivePoolAccounts,
@@ -43,7 +43,7 @@ import {
43
43
  fetchPoolAuthorizations,
44
44
  selectAccount
45
45
  } from "./chunk-QMYW3D6E.js";
46
- import "./chunk-MR6AV2UF.js";
46
+ import "./chunk-P7XM4NJG.js";
47
47
  import {
48
48
  VERSION,
49
49
  loadRunState,
@@ -53,7 +53,7 @@ import {
53
53
  shouldSkipStaleWarning,
54
54
  stateFilePath,
55
55
  writeRunState
56
- } from "./chunk-5WKHSZT7.js";
56
+ } from "./chunk-Y5EWXXFE.js";
57
57
  import {
58
58
  DEFAULT_ENV_ID,
59
59
  defaultBundledPath,
@@ -5,8 +5,8 @@ import {
5
5
  maybeWriteMemoryReport,
6
6
  safeHeap,
7
7
  sampleFromBytes
8
- } from "./chunk-MR6AV2UF.js";
9
- import "./chunk-5WKHSZT7.js";
8
+ } from "./chunk-P7XM4NJG.js";
9
+ import "./chunk-Y5EWXXFE.js";
10
10
  export {
11
11
  DEFAULT_THRESHOLD_MB,
12
12
  buildMemoryReport,
package/dist/merkle.js CHANGED
@@ -6,19 +6,19 @@ import {
6
6
  merkleizeKuboBackend,
7
7
  merkleizeWithStableOrder,
8
8
  rebuildOrderedCarFromBytes
9
- } from "./chunk-NEYQ2JMY.js";
9
+ } from "./chunk-JHD3OP3E.js";
10
10
  import "./chunk-KHVTYIIX.js";
11
11
  import "./chunk-KOSF5FDO.js";
12
12
  import "./chunk-FZWJV5AD.js";
13
13
  import "./chunk-S7EM5VMW.js";
14
- import "./chunk-5EWKYMDC.js";
15
- import "./chunk-QUQYXRTC.js";
16
- import "./chunk-6MDQDYVL.js";
14
+ import "./chunk-EJUBFDXU.js";
15
+ import "./chunk-3AJST77I.js";
16
+ import "./chunk-KLXIR7NQ.js";
17
17
  import "./chunk-C2TS5MER.js";
18
- import "./chunk-X6YUEHEH.js";
18
+ import "./chunk-MRRSSWRM.js";
19
19
  import "./chunk-QMYW3D6E.js";
20
- import "./chunk-MR6AV2UF.js";
21
- import "./chunk-5WKHSZT7.js";
20
+ import "./chunk-P7XM4NJG.js";
21
+ import "./chunk-Y5EWXXFE.js";
22
22
  import "./chunk-MGU5I7H5.js";
23
23
  import "./chunk-ZOC4GITL.js";
24
24
  import "./chunk-HOTQDYHD.js";
@@ -21,10 +21,10 @@ import {
21
21
  } from "../chunk-T7EEVWNU.js";
22
22
  import {
23
23
  WS_HEARTBEAT_TIMEOUT_MS
24
- } from "../chunk-X6YUEHEH.js";
24
+ } from "../chunk-MRRSSWRM.js";
25
25
  import "../chunk-QMYW3D6E.js";
26
- import "../chunk-MR6AV2UF.js";
27
- import "../chunk-5WKHSZT7.js";
26
+ import "../chunk-P7XM4NJG.js";
27
+ import "../chunk-Y5EWXXFE.js";
28
28
  import {
29
29
  loadEnvironments
30
30
  } from "../chunk-MGU5I7H5.js";
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  WS_HEARTBEAT_TIMEOUT_MS
3
- } from "../chunk-X6YUEHEH.js";
3
+ } from "../chunk-MRRSSWRM.js";
4
4
  import "../chunk-QMYW3D6E.js";
5
- import "../chunk-MR6AV2UF.js";
6
- import "../chunk-5WKHSZT7.js";
5
+ import "../chunk-P7XM4NJG.js";
6
+ import "../chunk-Y5EWXXFE.js";
7
7
  import {
8
8
  loadEnvironments
9
9
  } from "../chunk-MGU5I7H5.js";
package/dist/run-state.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  shouldSkipStaleWarning,
8
8
  stateFilePath,
9
9
  writeRunState
10
- } from "./chunk-5WKHSZT7.js";
10
+ } from "./chunk-Y5EWXXFE.js";
11
11
  export {
12
12
  VERSION,
13
13
  loadRunState,
@@ -29,9 +29,16 @@ type DeployErrorCategory = 'user' | 'environment' | 'internal' | 'unknown';
29
29
  declare function classifyDeployError(msg: string): DeployErrorCategory;
30
30
  declare function classifySadReason(message: string): string;
31
31
  declare function computeDeployOutcome(errorCategory: DeployErrorCategory | null, isSad: boolean, sadReason: string): string;
32
- type DeployErrorKind = 'contract-revert' | 'chain-timeout' | 'nonce-stale' | 'connection' | 'unknown';
32
+ type DeployErrorKind = 'contract-revert' | 'chain-timeout' | 'nonce-stale' | 'connection' | 'naming.pop_required' | 'naming.already_owned' | 'naming.subdomain_orphan' | 'verify.contenthash_mismatch' | 'verify.dagpb_not_finalised' | 'network.recovery_exhausted' | 'chain.api_timeout' | 'tool.invariant' | 'unknown';
33
33
  declare function classifyErrorKind(msg: string): DeployErrorKind;
34
34
  declare function sanitizeErrorMessage(msg: string): string;
35
+ /**
36
+ * Classify an error message's *shape* (length, presence of certain patterns)
37
+ * without excerpting content. The result is a comma-joined list of tags;
38
+ * future analysis of scrubbed events will use this to identify which content
39
+ * shape triggered Sentry's PII scrubber, so we can build a real sanitiser.
40
+ */
41
+ declare function analyseErrorPattern(msg: string): string;
35
42
  declare function withSpan<T>(op: string, description: string, attributes: Record<string, string | number | boolean | undefined>, fn: () => T | Promise<T>): Promise<T>;
36
43
  declare function sampleMemory(stage: string): void;
37
44
  declare function withDeploySpan<T>(domain: string, fn: () => T | Promise<T>): Promise<T>;
@@ -46,4 +53,4 @@ declare function setDeploySentryTag(key: string, value: string): void;
46
53
  declare function captureWarning(message: string, context?: Record<string, unknown>): void;
47
54
  declare function flush(): Promise<void>;
48
55
 
49
- export { type DeployErrorCategory, type DeployErrorKind, type InternalContextSignals, VERSION, __setDeployRootSpanForTest, __setSentryForTest, captureWarning, classifyDeployError, classifyErrorKind, classifySadReason, closeTelemetry, computeDeployOutcome, flush, getCurrentSentryTraceId, getDeployAttributes, initTelemetry, isExpectedError, isInternalContext, isInternalContextFromSignals, markRelaunchOomHintShown, resolveRepo, resolveRunner, resolveRunnerType, sampleMemory, sanitizeBranch, sanitizeErrorMessage, sanitizeRepo, scrubPaths, setDeployAttribute, setDeployReportContext, setDeploySentryTag, setRunStateActive, truncateAddress, withDeploySpan, withSpan };
56
+ export { type DeployErrorCategory, type DeployErrorKind, type InternalContextSignals, VERSION, __setDeployRootSpanForTest, __setSentryForTest, analyseErrorPattern, captureWarning, classifyDeployError, classifyErrorKind, classifySadReason, closeTelemetry, computeDeployOutcome, flush, getCurrentSentryTraceId, getDeployAttributes, initTelemetry, isExpectedError, isInternalContext, isInternalContextFromSignals, markRelaunchOomHintShown, resolveRepo, resolveRunner, resolveRunnerType, sampleMemory, sanitizeBranch, sanitizeErrorMessage, sanitizeRepo, scrubPaths, setDeployAttribute, setDeployReportContext, setDeploySentryTag, setRunStateActive, truncateAddress, withDeploySpan, withSpan };
package/dist/telemetry.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  VERSION,
3
3
  __setDeployRootSpanForTest,
4
4
  __setSentryForTest,
5
+ analyseErrorPattern,
5
6
  captureWarning,
6
7
  classifyDeployError,
7
8
  classifyErrorKind,
@@ -31,12 +32,13 @@ import {
31
32
  truncateAddress,
32
33
  withDeploySpan,
33
34
  withSpan
34
- } from "./chunk-MR6AV2UF.js";
35
- import "./chunk-5WKHSZT7.js";
35
+ } from "./chunk-P7XM4NJG.js";
36
+ import "./chunk-Y5EWXXFE.js";
36
37
  export {
37
38
  VERSION,
38
39
  __setDeployRootSpanForTest,
39
40
  __setSentryForTest,
41
+ analyseErrorPattern,
40
42
  captureWarning,
41
43
  classifyDeployError,
42
44
  classifyErrorKind,
@@ -11,9 +11,9 @@ import {
11
11
  isPreReleaseVersion,
12
12
  preReleaseWarning,
13
13
  promptYesNo
14
- } from "./chunk-QUQYXRTC.js";
15
- import "./chunk-MR6AV2UF.js";
16
- import "./chunk-5WKHSZT7.js";
14
+ } from "./chunk-3AJST77I.js";
15
+ import "./chunk-P7XM4NJG.js";
16
+ import "./chunk-Y5EWXXFE.js";
17
17
  export {
18
18
  assessVersion,
19
19
  checkNodeVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulletin-deploy",
3
- "version": "0.7.26",
3
+ "version": "0.7.27-rc.2",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,8 +2,15 @@
2
2
  // Selective retry wrapper for release E2E.
3
3
  //
4
4
  // Spawns a child process (the deploy CLI / test runner invocation), captures
5
- // stderr, classifies the failure mode, and exits with 75 on flake-class
6
- // matches (retry-eligible) or the child's own exit code otherwise.
5
+ // stdout AND stderr, classifies the failure mode, and exits with 75 on
6
+ // flake-class matches (retry-eligible) or the child's own exit code otherwise.
7
+ //
8
+ // Both streams are passed through to the parent's stdout/stderr so the GH
9
+ // Actions job log still shows everything live.
10
+ //
11
+ // node --test captures each test-file subprocess's output and re-emits it as
12
+ // TAP YAML on its own stdout — so deploy CLI errors (e.g. "ChainHead
13
+ // disjointed") appear on stdout, not stderr. Capturing both is required.
7
14
  //
8
15
  // Configure nick-fields/retry@v3 with retry_on_exit_code: 75 so retries only
9
16
  // fire for the named transient classes. See
@@ -20,12 +27,16 @@ const FLAKE_PATTERNS = [
20
27
  "ChainHead disjointed", // RPC reorg / WS flake
21
28
  "Connection lost", // WS hard drop
22
29
  "Account mapping did not take effect", // Revive mapping race
30
+ "requires Node.js >=22", // parity-default runner downgrade (Node v18) — infra flake
31
+ "received a shutdown signal", // runner process killed mid-job — CI infra flake
23
32
  ];
24
33
 
25
- export function classifyForRetry(stderr, childExitCode = 1) {
34
+ // output: combined stdout+stderr text from the child. Any flake pattern
35
+ // appearing anywhere in the child's output makes the run retry-eligible.
36
+ export function classifyForRetry(output, childExitCode = 1) {
26
37
  if (childExitCode === 0) return 0;
27
38
  for (const pat of FLAKE_PATTERNS) {
28
- if (stderr.includes(pat)) return 75;
39
+ if (output.includes(pat)) return 75;
29
40
  }
30
41
  return childExitCode || 1;
31
42
  }
@@ -37,20 +48,24 @@ if (import.meta.url === `file://${process.argv[1]}`) {
37
48
  console.error("usage: release-retry-wrapper.mjs <command> [args...]");
38
49
  process.exit(2);
39
50
  }
40
- const child = spawn(cmd, args, { stdio: ["inherit", "inherit", "pipe"] });
41
- let stderrBuf = "";
51
+ const child = spawn(cmd, args, { stdio: ["inherit", "pipe", "pipe"] });
52
+ let outputBuf = "";
53
+ child.stdout.on("data", (chunk) => {
54
+ process.stdout.write(chunk); // pass through stdout to job log
55
+ outputBuf += chunk.toString();
56
+ });
42
57
  child.stderr.on("data", (chunk) => {
43
- process.stderr.write(chunk); // pass through to job log
44
- stderrBuf += chunk.toString();
58
+ process.stderr.write(chunk); // pass through stderr to job log
59
+ outputBuf += chunk.toString();
45
60
  });
46
61
  child.on("error", (err) => {
47
62
  process.stderr.write(`[release-retry-wrapper] failed to spawn: ${err.message}\n`);
48
63
  process.exit(1);
49
64
  });
50
- // Use `close` (not `exit`) so the stderr pipe is fully drained before we
65
+ // Use `close` (not `exit`) so both pipes are fully drained before we
51
66
  // classify — `exit` can fire before the last `data` chunk lands.
52
67
  child.on("close", (code) => {
53
- const cls = classifyForRetry(stderrBuf, code ?? 1);
68
+ const cls = classifyForRetry(outputBuf, code ?? 1);
54
69
  if (cls === 75) {
55
70
  console.error("[release-retry-wrapper] flake-class match — exiting 75 to signal retry");
56
71
  }