bulletin-deploy 0.6.16 → 0.7.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/README.md CHANGED
@@ -47,9 +47,6 @@ bulletin-deploy ./dist my-app00.dot
47
47
 
48
48
  # Custom RPC endpoint
49
49
  bulletin-deploy --rpc wss://custom-bulletin.example.com ./dist my-app00.dot
50
-
51
- # Deploy and publish to the Playground remix registry
52
- bulletin-deploy --playground ./dist my-app00.dot
53
50
  ```
54
51
 
55
52
  ### All options
@@ -63,7 +60,6 @@ Options:
63
60
  Bulletin direct signer and the DotNS registration signer then
64
61
  run as that derived account. Useful when running parallel
65
62
  deploys against the same root mnemonic without nonce contention.
66
- --playground Publish to the Playground remix registry
67
63
  --js-merkle Use pure-JS merkleization (no IPFS Kubo binary required)
68
64
  --pool-size N Number of pool accounts (default: 10)
69
65
  --tag "..." Free-form label attached to the deploy span as deploy.tag
@@ -111,18 +107,6 @@ Limitations / follow-ups:
111
107
  - **Mirror failures are non-fatal.** The source of truth is Bulletin + DotNS; the mirror is a cache. Failures log and let the deploy succeed.
112
108
  - **GitHub Pages build latency.** The CAR lands on `gh-pages` immediately; Pages serves it after the build completes (~1–2 min in practice). Hosts should fall back to Bulletin while the 404 window lasts.
113
109
 
114
- ### Playground registry
115
-
116
- By default, deploys only upload to Bulletin storage and register the DotNS domain. The **Playground remix registry** is an on-chain app directory that makes your deploy visible in [Polkadot Playground](https://playground.polkadot.cloud).
117
-
118
- To publish to it, pass `--playground`:
119
-
120
- ```bash
121
- bulletin-deploy --playground ./dist my-app.dot
122
- ```
123
-
124
- This requires `cdm.json` in your project root (shipped with bulletin-deploy) and a git remote origin.
125
-
126
110
  ## GitHub Actions
127
111
 
128
112
  1. Copy `workflows/deploy-on-pr.yml` to your repo's `.github/workflows/` directory
@@ -275,6 +259,7 @@ Sentry telemetry is **off by default for external users**. It's automatically on
275
259
 
276
260
  - `BULLETIN_DEPLOY_TELEMETRY=1` — explicit opt-in (useful if you want to help us debug an issue you're seeing).
277
261
  - `BULLETIN_DEPLOY_TELEMETRY=0` — force off regardless of context.
262
+ - When running under **Bun**, the Parity-internal memory-report diagnostic bundle is skipped (basic deploy telemetry still works). The bundle relies on Node's `v8` module, which Bun implements only partially.
278
263
 
279
264
  Detection signals (OR'd together):
280
265
  1. `GITHUB_REPOSITORY` matches a known-internal org — Parity-owned CI workflow.
@@ -331,20 +316,21 @@ Runs `test/test.js` + `test/pool.test.js` + `test/helpers/e2e-helpers.test.js` v
331
316
 
332
317
  ### Live-testnet E2E
333
318
 
334
- Three scenarios land on Paseo Bulletin:
319
+ Four scenarios land on Paseo Bulletin:
335
320
 
336
321
  - **S1** — happy path on a stable label (`e2epool.dot` / `e2edirect.dot`)
337
322
  - **S2** — fresh registration via commit-reveal (nightly only)
338
323
  - **S3** — deploy to `e2eowned.dot` (owned by a different account), expects `EXIT_CODE_NO_RETRY` (78) and the "owned by a different account" error message
324
+ - **S4** — deploy with `--gh-pages-mirror`, waits for GitHub Pages to serve the just-pushed manifest (CID-freshness check), then byte-compares the CAR on Pages against a pre-upload dump to confirm the mirror is an exact copy of what went to Bulletin
339
325
 
340
- **Prerequisites** (one-time per testnet lifetime): see [`docs/e2e-bootstrap.md`](docs/e2e-bootstrap.md). Grants Alice PoP Full, funds+maps Bob, pre-registers `e2eowned.dot` to Bob via `dotns-cli`.
326
+ **Prerequisites** (one-time per testnet lifetime): see [`docs/e2e-bootstrap.md`](docs/e2e-bootstrap.md). Grants Alice PoP Full, funds+maps Bob, pre-registers `e2eowned.dot` to Bob via `dotns-cli`. S4 additionally needs GitHub Pages enabled on the repo with `gh-pages` as the source branch and a token with `contents: write` (the workflow's `GITHUB_TOKEN` provides this; locally your git credentials must be able to push).
341
327
 
342
328
  **Local launchers:**
343
329
 
344
330
  ```bash
345
331
  npm run test:e2e:smoke # 1 scenario (S1 pool/js) ~5 min
346
- npm run test:e2e:pr # 3 scenarios (matches per-PR CI) ~15 min
347
- npm run test:e2e:nightly # 7 scenarios (matches nightly CI) ~30–45 min
332
+ npm run test:e2e:pr # 4 scenarios (matches per-PR CI) ~20 min
333
+ npm run test:e2e:nightly # 8 scenarios (matches nightly CI) ~30–45 min
348
334
  ```
349
335
 
350
336
  All three run through to completion even if one fails; a colored summary prints at the end with per-scenario pass/fail, timing, JUnit report paths, and a pre-filtered Sentry trace link.
@@ -4,9 +4,13 @@ import { deploy, DEFAULT_BULLETIN_RPC, DEFAULT_POOL_SIZE, NonRetryableError, EXI
4
4
  import { bootstrapPool } from "../dist/pool.js";
5
5
  import { VERSION } from "../dist/telemetry.js";
6
6
  import { handleFailedDeploy, preReleaseWarning } from "../dist/version-check.js";
7
- import { setDeployContext } from "../dist/bug-report.js";
7
+ import { setDeployContext, installLogCapture, buildCliFlagsSummary } from "../dist/bug-report.js";
8
8
  import * as fs from "fs";
9
9
 
10
+ // Install early so anything printed during flag parsing / preflight is
11
+ // available to the bug-report log tail.
12
+ installLogCapture();
13
+
10
14
  const args = process.argv.slice(2);
11
15
 
12
16
  const flags = {};
@@ -18,7 +22,6 @@ for (let i = 0; i < args.length; i++) {
18
22
  else if (args[i] === "--derivation-path") { flags.derivationPath = args[++i]; }
19
23
  else if (args[i] === "--rpc") { flags.rpc = args[++i]; }
20
24
  else if (args[i] === "--password") { flags.password = args[++i]; }
21
- else if (args[i] === "--playground") { flags.playground = true; }
22
25
  else if (args[i] === "--js-merkle") { flags.jsMerkle = true; }
23
26
  else if (args[i] === "--tag") { flags.tag = args[++i]; }
24
27
  else if (args[i] === "--gh-pages-mirror") { flags.ghPagesMirror = true; }
@@ -45,7 +48,6 @@ Options:
45
48
  --rpc wss://... Bulletin RPC (or set BULLETIN_RPC env var)
46
49
  --pool-size N Number of pool accounts (default: 10)
47
50
  --password "..." Encrypt SPA content (users will be prompted to decrypt)
48
- --playground Publish to the playground remix registry
49
51
  --js-merkle Use pure-JS merkleization (no IPFS Kubo binary required)
50
52
  --tag "..." Label deploy in telemetry (or set DEPLOY_TAG env var); see Telemetry in README
51
53
  --gh-pages-mirror After deploy, push the CAR to the current repo's gh-pages branch
@@ -69,16 +71,28 @@ try {
69
71
  if (!domain) { console.error("Error: domain required (e.g. my-app.dot)"); process.exit(1); }
70
72
  if (!fs.existsSync(buildDir)) { console.error(`Error: ${buildDir} does not exist`); process.exit(1); }
71
73
 
74
+ const effectiveRpc = flags.rpc ?? process.env.BULLETIN_RPC ?? DEFAULT_BULLETIN_RPC;
75
+ const deployTag = flags.tag ?? process.env.DEPLOY_TAG;
76
+ const ci = process.env.GITHUB_ACTIONS === "true" ? {
77
+ runUrl: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
78
+ ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}${process.env.GITHUB_RUN_ATTEMPT ? `/attempts/${process.env.GITHUB_RUN_ATTEMPT}` : ""}`
79
+ : undefined,
80
+ workflow: process.env.GITHUB_WORKFLOW,
81
+ job: process.env.GITHUB_JOB,
82
+ sha: process.env.GITHUB_SHA,
83
+ } : undefined;
72
84
  setDeployContext({
73
85
  domain,
74
- rpc: flags.rpc,
86
+ rpc: effectiveRpc,
75
87
  repo: process.env.GITHUB_REPOSITORY,
76
88
  branch: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME,
77
89
  signerMode: flags.mnemonic ? "direct" : "pool",
90
+ deployTag,
91
+ cliFlags: buildCliFlagsSummary(flags),
92
+ ci,
78
93
  });
79
94
 
80
95
  const result = await deploy(buildDir, domain, {
81
- playground: flags.playground,
82
96
  mnemonic: flags.mnemonic,
83
97
  derivationPath: flags.derivationPath,
84
98
  rpc: flags.rpc,
@@ -6,13 +6,24 @@ interface DeployContext {
6
6
  chunkCount?: number;
7
7
  totalSize?: string;
8
8
  rpc?: string;
9
- sentryTraceId?: string;
9
+ deployTag?: string;
10
+ cliFlags?: string;
11
+ ci?: {
12
+ runUrl?: string;
13
+ workflow?: string;
14
+ job?: string;
15
+ sha?: string;
16
+ };
10
17
  }
11
18
  declare function setDeployContext(ctx: Partial<DeployContext>): void;
19
+ declare function installLogCapture(): void;
20
+ declare function getCapturedTail(): string;
21
+ declare function scrubSecrets(text: string): string;
22
+ declare function buildCliFlagsSummary(flags: Record<string, unknown>): string;
12
23
  declare function buildReportBody(error: Error): string;
13
24
  declare function buildTitle(error: Error): string;
14
25
  declare function buildLabels(error: Error): string[];
15
- declare function createGhIssue(title: string, body: string, labels: string[]): void;
26
+ declare function createGhIssue(title: string, body: string, labels: string[]): string;
16
27
  declare function offerBugReport(error: Error): Promise<void>;
17
28
 
18
- export { buildLabels, buildReportBody, buildTitle, createGhIssue, offerBugReport, setDeployContext };
29
+ export { buildCliFlagsSummary, buildLabels, buildReportBody, buildTitle, createGhIssue, getCapturedTail, installLogCapture, offerBugReport, scrubSecrets, setDeployContext };
@@ -1,121 +1,27 @@
1
1
  import {
2
- classifyErrorArea,
3
- isInteractive,
4
- promptYesNo
5
- } from "./chunk-BGLOVKHX.js";
6
- import {
7
- VERSION
8
- } from "./chunk-LF3XAUCI.js";
2
+ buildCliFlagsSummary,
3
+ buildLabels,
4
+ buildReportBody,
5
+ buildTitle,
6
+ createGhIssue,
7
+ getCapturedTail,
8
+ installLogCapture,
9
+ offerBugReport,
10
+ scrubSecrets,
11
+ setDeployContext
12
+ } from "./chunk-5N4VL73E.js";
13
+ import "./chunk-DYKCMHZ6.js";
14
+ import "./chunk-PH43YXEE.js";
9
15
  import "./chunk-QGM4M3NI.js";
10
-
11
- // src/bug-report.ts
12
- import { execSync, execFileSync } from "child_process";
13
- import * as os from "os";
14
- var _deployContext = {};
15
- function setDeployContext(ctx) {
16
- _deployContext = { ..._deployContext, ...ctx };
17
- }
18
- function hasGhCli() {
19
- try {
20
- execSync("gh --version", { stdio: "pipe" });
21
- return true;
22
- } catch {
23
- return false;
24
- }
25
- }
26
- function buildReportBody(error) {
27
- const lines = [
28
- "## Environment",
29
- "",
30
- `- **bulletin-deploy**: ${VERSION}`,
31
- `- **Node.js**: ${process.version}`,
32
- `- **OS**: ${os.platform()} ${os.arch()} ${os.release()}`,
33
- "",
34
- "## Error",
35
- "",
36
- "```",
37
- error.stack || error.message,
38
- "```",
39
- ""
40
- ];
41
- const ctx = _deployContext;
42
- if (ctx.domain || ctx.repo || ctx.rpc) {
43
- lines.push("## Deploy Context", "");
44
- if (ctx.domain) lines.push(`- **Domain**: ${ctx.domain}`);
45
- if (ctx.repo) lines.push(`- **Repo**: ${ctx.repo}`);
46
- if (ctx.branch) lines.push(`- **Branch**: ${ctx.branch}`);
47
- if (ctx.signerMode) lines.push(`- **Signer mode**: ${ctx.signerMode}`);
48
- if (ctx.chunkCount != null) lines.push(`- **Chunks**: ${ctx.chunkCount}`);
49
- if (ctx.totalSize) lines.push(`- **Total size**: ${ctx.totalSize}`);
50
- if (ctx.rpc) lines.push(`- **RPC**: ${ctx.rpc}`);
51
- if (ctx.sentryTraceId) lines.push(`- **Sentry trace**: ${ctx.sentryTraceId}`);
52
- lines.push("");
53
- }
54
- return lines.join("\n");
55
- }
56
- function buildTitle(error) {
57
- const msg = error.message.slice(0, 60);
58
- return `[deploy-bug] ${msg}`;
59
- }
60
- function buildLabels(error) {
61
- const labels = ["bug", "auto-report"];
62
- const area = classifyErrorArea(error.message);
63
- if (area) labels.push(area);
64
- return labels;
65
- }
66
- function createGhIssue(title, body, labels) {
67
- const args = [
68
- "issue",
69
- "create",
70
- "--repo",
71
- "paritytech/bulletin-deploy",
72
- "--title",
73
- title,
74
- ...labels.flatMap((l) => ["--label", l]),
75
- "--body-file",
76
- "-"
77
- ];
78
- execFileSync("gh", args, { input: body, stdio: ["pipe", "inherit", "inherit"] });
79
- }
80
- async function offerBugReport(error) {
81
- if (!isInteractive()) return;
82
- const yes = await promptYesNo("\n This looks like a bug. Open an issue with debug info? [Y/n] ");
83
- if (!yes) return;
84
- const title = buildTitle(error);
85
- const body = buildReportBody(error);
86
- const labels = buildLabels(error);
87
- if (hasGhCli()) {
88
- try {
89
- createGhIssue(title, body, labels);
90
- console.error(" Issue created.");
91
- } catch {
92
- try {
93
- console.error(" Retrying without labels...");
94
- createGhIssue(title, body, []);
95
- console.error(" Issue created (without labels).");
96
- } catch {
97
- console.error(" Failed to create issue. Debug info below:\n");
98
- printFallback(title, body, labels);
99
- }
100
- }
101
- } else {
102
- console.error("\n gh CLI not found. Debug info below \u2014 paste into a new issue:\n");
103
- console.error(` https://github.com/paritytech/bulletin-deploy/issues/new
104
- `);
105
- printFallback(title, body, labels);
106
- }
107
- }
108
- function printFallback(title, body, labels) {
109
- console.error(` Title: ${title}`);
110
- console.error(` Labels: ${labels.join(", ")}
111
- `);
112
- console.error(body);
113
- }
114
16
  export {
17
+ buildCliFlagsSummary,
115
18
  buildLabels,
116
19
  buildReportBody,
117
20
  buildTitle,
118
21
  createGhIssue,
22
+ getCapturedTail,
23
+ installLogCapture,
119
24
  offerBugReport,
25
+ scrubSecrets,
120
26
  setDeployContext
121
27
  };
@@ -12,6 +12,35 @@ var MirrorSkipped = class extends Error {
12
12
  this.name = "MirrorSkipped";
13
13
  }
14
14
  };
15
+ async function pollMirrorFreshness(mirrorUrl2, expectedCid, opts = {}) {
16
+ const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1e3;
17
+ const intervalMs = opts.intervalMs ?? 1e4;
18
+ const fetchFn = opts.fetchFn ?? fetch;
19
+ const manifestUrl = mirrorUrl2.replace(/\.car$/, ".json");
20
+ const started = Date.now();
21
+ const deadline = started + timeoutMs;
22
+ let attempts = 0;
23
+ let lastCid = null;
24
+ let lastStatus = 0;
25
+ while (Date.now() < deadline) {
26
+ attempts++;
27
+ try {
28
+ const res = await fetchFn(manifestUrl, { redirect: "follow", cache: "no-store" });
29
+ lastStatus = res.status;
30
+ if (res.status === 200) {
31
+ const m = await res.json();
32
+ if (m.cid === expectedCid) {
33
+ return { verified: true, attempts, durationMs: Date.now() - started, lastCid: m.cid, lastStatus };
34
+ }
35
+ lastCid = m.cid ?? null;
36
+ }
37
+ } catch {
38
+ }
39
+ if (Date.now() + intervalMs >= deadline) break;
40
+ await new Promise((r) => setTimeout(r, intervalMs));
41
+ }
42
+ return { verified: false, attempts, durationMs: Date.now() - started, lastCid, lastStatus };
43
+ }
15
44
  function parseGitRemoteUrl(url) {
16
45
  const trimmed = url.trim();
17
46
  const ssh = trimmed.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
@@ -91,7 +120,7 @@ async function mirrorToGitHubPages(input) {
91
120
  }
92
121
  if (input.carBytes.length > GH_PAGES_MIRROR_MAX_BYTES) {
93
122
  const mb = (input.carBytes.length / 1024 / 1024).toFixed(1);
94
- throw new MirrorSkipped(`CAR is ${mb} MB; GitHub limits single files to 100 MB. Mirror skipped.`);
123
+ throw new MirrorSkipped(`CAR is ${mb} MB, exceeds GitHub's 100 MB single-file soft limit. Pages can't host this CAR \u2014 the on-chain deploy still succeeds and hosts will fall back to Bulletin.`);
95
124
  }
96
125
  const domainFilename = normalizeDomainFilename(input.domain);
97
126
  const { owner, repo } = ownerRepo;
@@ -112,8 +141,8 @@ async function mirrorToGitHubPages(input) {
112
141
  branchExists = false;
113
142
  }
114
143
  if (branchExists) {
115
- runGit(["fetch", "origin", `${GH_PAGES_MIRROR_BRANCH}:${GH_PAGES_MIRROR_BRANCH}`, "--depth=1"], repoPath);
116
- runGit(["worktree", "add", workTree, GH_PAGES_MIRROR_BRANCH], repoPath);
144
+ runGit(["fetch", "origin", GH_PAGES_MIRROR_BRANCH, "--depth=1"], repoPath);
145
+ runGit(["worktree", "add", "--detach", workTree, `origin/${GH_PAGES_MIRROR_BRANCH}`], repoPath);
117
146
  } else {
118
147
  runGit(["worktree", "add", "--detach", workTree, "HEAD"], repoPath);
119
148
  runGit(["checkout", "--orphan", GH_PAGES_MIRROR_BRANCH], workTree);
@@ -173,6 +202,7 @@ export {
173
202
  GH_PAGES_MIRROR_DIR,
174
203
  GH_PAGES_MIRROR_BRANCH,
175
204
  MirrorSkipped,
205
+ pollMirrorFreshness,
176
206
  parseGitRemoteUrl,
177
207
  resolveOwnerRepo,
178
208
  resolveSourceCommit,
@@ -0,0 +1,223 @@
1
+ import {
2
+ classifyErrorArea,
3
+ isInteractive,
4
+ promptYesNo
5
+ } from "./chunk-DYKCMHZ6.js";
6
+ import {
7
+ VERSION,
8
+ getCurrentSentryTraceId
9
+ } from "./chunk-PH43YXEE.js";
10
+
11
+ // src/bug-report.ts
12
+ import { execSync, execFileSync } from "child_process";
13
+ import * as os from "os";
14
+ var _deployContext = {};
15
+ function setDeployContext(ctx) {
16
+ _deployContext = { ..._deployContext, ...ctx };
17
+ }
18
+ function hasGhCli() {
19
+ try {
20
+ execSync("gh --version", { stdio: "pipe" });
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+ var LOG_TAIL_BYTES = 32 * 1024;
27
+ var _logBuffer = "";
28
+ var _logCaptureInstalled = false;
29
+ function installLogCapture() {
30
+ if (_logCaptureInstalled) return;
31
+ _logCaptureInstalled = true;
32
+ const append = (args) => {
33
+ const line = args.map((a) => typeof a === "string" ? a : safeStringify(a)).join(" ") + "\n";
34
+ _logBuffer += line;
35
+ if (_logBuffer.length > LOG_TAIL_BYTES * 2) {
36
+ _logBuffer = _logBuffer.slice(_logBuffer.length - LOG_TAIL_BYTES);
37
+ }
38
+ };
39
+ const wrap = (key) => {
40
+ const orig = console[key].bind(console);
41
+ console[key] = (...a) => {
42
+ append(a);
43
+ orig(...a);
44
+ };
45
+ };
46
+ wrap("log");
47
+ wrap("error");
48
+ wrap("warn");
49
+ }
50
+ function safeStringify(v) {
51
+ try {
52
+ if (v instanceof Error) return v.stack || v.message;
53
+ return JSON.stringify(v);
54
+ } catch {
55
+ return String(v);
56
+ }
57
+ }
58
+ function getCapturedTail() {
59
+ if (!_logBuffer) return "";
60
+ const tail = _logBuffer.length > LOG_TAIL_BYTES ? "\u2026 [truncated]\n" + _logBuffer.slice(_logBuffer.length - LOG_TAIL_BYTES) : _logBuffer;
61
+ return scrubSecrets(tail);
62
+ }
63
+ function scrubSecrets(text) {
64
+ if (!text) return text;
65
+ let out = text;
66
+ out = out.replace(/(--mnemonic(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi, "$1<REDACTED>");
67
+ out = out.replace(/(--password(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi, "$1<REDACTED>");
68
+ out = out.replace(/\b(MNEMONIC|PASSWORD|BULLETIN_MNEMONIC|GITHUB_TOKEN|GH_TOKEN|NPM_TOKEN|SENTRY_AUTH_TOKEN)=([^\s]+)/gi, "$1=<REDACTED>");
69
+ out = out.replace(/\b(ghp|ghs|gho|ghu|ghr)_[A-Za-z0-9]{20,}\b/g, "<REDACTED_TOKEN>");
70
+ out = out.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "<REDACTED_TOKEN>");
71
+ out = out.replace(/\b(?:[a-z]{3,10}\s+){11}[a-z]{3,10}\b/g, "<REDACTED_MNEMONIC>");
72
+ out = out.replace(/([a-z][a-z0-9+.-]*:\/\/)[^:@\s]+:[^@\s]+@/gi, "$1<REDACTED>@");
73
+ return out;
74
+ }
75
+ function buildCliFlagsSummary(flags) {
76
+ const parts = [];
77
+ if (flags.bootstrap) parts.push("--bootstrap");
78
+ if (flags.jsMerkle) parts.push("--js-merkle");
79
+ if (flags.ghPagesMirror) parts.push("--gh-pages-mirror");
80
+ if (flags.poolSize != null) parts.push(`--pool-size ${String(flags.poolSize)}`);
81
+ if (typeof flags.tag === "string" && flags.tag) parts.push(`--tag ${flags.tag}`);
82
+ if (flags.mnemonic) parts.push("--mnemonic <set>");
83
+ if (flags.password) parts.push("--password <set>");
84
+ if (flags.derivationPath) parts.push("--derivation-path <set>");
85
+ if (typeof flags.rpc === "string" && flags.rpc) parts.push("--rpc <set>");
86
+ return parts.join(" ");
87
+ }
88
+ function buildReportBody(error) {
89
+ const lines = [
90
+ "## Environment",
91
+ "",
92
+ `- **bulletin-deploy**: ${VERSION}`,
93
+ `- **Node.js**: ${process.version}`,
94
+ `- **OS**: ${os.platform()} ${os.arch()} ${os.release()}`,
95
+ "",
96
+ "## Error",
97
+ "",
98
+ "```",
99
+ scrubSecrets(error.stack || error.message),
100
+ "```",
101
+ ""
102
+ ];
103
+ const ctx = _deployContext;
104
+ const traceId = getCurrentSentryTraceId();
105
+ const hasCtx = ctx.domain || ctx.repo || ctx.rpc || ctx.cliFlags || ctx.ci?.runUrl || traceId;
106
+ if (hasCtx) {
107
+ lines.push("## Deploy Context", "");
108
+ if (ctx.domain) lines.push(`- **Domain**: ${ctx.domain}`);
109
+ if (ctx.repo) lines.push(`- **Repo**: ${ctx.repo}`);
110
+ if (ctx.branch) lines.push(`- **Branch**: ${ctx.branch}`);
111
+ if (ctx.signerMode) lines.push(`- **Signer mode**: ${ctx.signerMode}`);
112
+ if (ctx.chunkCount != null) lines.push(`- **Chunks**: ${ctx.chunkCount}`);
113
+ if (ctx.totalSize) lines.push(`- **Total size**: ${ctx.totalSize}`);
114
+ if (ctx.rpc) lines.push(`- **RPC**: ${ctx.rpc}`);
115
+ if (ctx.deployTag) lines.push(`- **Deploy tag**: ${ctx.deployTag}`);
116
+ if (ctx.cliFlags) lines.push(`- **CLI flags**: \`${ctx.cliFlags}\``);
117
+ if (traceId) lines.push(`- **Sentry trace**: ${traceId}`);
118
+ lines.push("");
119
+ }
120
+ if (ctx.ci?.runUrl) {
121
+ lines.push("## CI", "");
122
+ lines.push(`- **Run**: ${ctx.ci.runUrl}`);
123
+ if (ctx.ci.workflow) lines.push(`- **Workflow**: ${ctx.ci.workflow}`);
124
+ if (ctx.ci.job) lines.push(`- **Job**: ${ctx.ci.job}`);
125
+ if (ctx.ci.sha) lines.push(`- **SHA**: ${ctx.ci.sha}`);
126
+ lines.push("");
127
+ }
128
+ const tail = getCapturedTail();
129
+ if (tail) {
130
+ lines.push("## Log tail", "", "<details><summary>Last ~32 KB of stdout/stderr (secrets scrubbed)</summary>", "", "```", tail.trimEnd(), "```", "", "</details>", "");
131
+ }
132
+ return lines.join("\n");
133
+ }
134
+ function buildTitle(error) {
135
+ const msg = error.message.slice(0, 60);
136
+ return `[deploy-bug] ${msg}`;
137
+ }
138
+ function buildLabels(error) {
139
+ const labels = ["bug", "auto-report"];
140
+ const area = classifyErrorArea(error.message);
141
+ if (area) labels.push(area);
142
+ return labels;
143
+ }
144
+ function createGhIssue(title, body, labels) {
145
+ const args = [
146
+ "issue",
147
+ "create",
148
+ "--repo",
149
+ "paritytech/bulletin-deploy",
150
+ "--title",
151
+ title,
152
+ ...labels.flatMap((l) => ["--label", l]),
153
+ "--body-file",
154
+ "-"
155
+ ];
156
+ const out = execFileSync("gh", args, { input: body, stdio: ["pipe", "pipe", "inherit"] });
157
+ return out.toString("utf8").trim();
158
+ }
159
+ function applyCoreLabels(issueUrl) {
160
+ try {
161
+ execFileSync(
162
+ "gh",
163
+ ["issue", "edit", issueUrl, "--add-label", "bug", "--add-label", "auto-report"],
164
+ { stdio: ["ignore", "pipe", "pipe"] }
165
+ );
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+ async function offerBugReport(error) {
172
+ if (!isInteractive()) return;
173
+ const yes = await promptYesNo("\n This looks like a bug. Open an issue with debug info? [Y/n] ");
174
+ if (!yes) return;
175
+ const title = buildTitle(error);
176
+ const body = buildReportBody(error);
177
+ const labels = buildLabels(error);
178
+ if (!hasGhCli()) {
179
+ console.error("\n gh CLI not found. Debug info below \u2014 paste into a new issue:\n");
180
+ console.error(` https://github.com/paritytech/bulletin-deploy/issues/new
181
+ `);
182
+ printFallback(title, body, labels);
183
+ return;
184
+ }
185
+ try {
186
+ const url = createGhIssue(title, body, labels);
187
+ console.error(` Issue created: ${url}`);
188
+ return;
189
+ } catch {
190
+ }
191
+ try {
192
+ console.error(" Retrying without labels...");
193
+ const url = createGhIssue(title, body, []);
194
+ const applied = applyCoreLabels(url);
195
+ if (applied) {
196
+ console.error(` Issue created: ${url} (labels applied after retry)`);
197
+ } else {
198
+ console.error(` Issue created: ${url} (labels could not be applied; please add 'bug' and 'auto-report' manually)`);
199
+ }
200
+ } catch {
201
+ console.error(" Failed to create issue. Debug info below:\n");
202
+ printFallback(title, body, labels);
203
+ }
204
+ }
205
+ function printFallback(title, body, labels) {
206
+ console.error(` Title: ${title}`);
207
+ console.error(` Labels: ${labels.join(", ")}
208
+ `);
209
+ console.error(body);
210
+ }
211
+
212
+ export {
213
+ setDeployContext,
214
+ installLogCapture,
215
+ getCapturedTail,
216
+ scrubSecrets,
217
+ buildCliFlagsSummary,
218
+ buildReportBody,
219
+ buildTitle,
220
+ buildLabels,
221
+ createGhIssue,
222
+ offerBugReport
223
+ };
@@ -12,8 +12,11 @@ var CidPreservingBlockstore = class {
12
12
  *all() {
13
13
  yield* this.data.values();
14
14
  }
15
+ clear() {
16
+ this.data.clear();
17
+ }
15
18
  };
16
- function walkDirectory(dirPath, prefix = "") {
19
+ function* walkDirectoryLazy(dirPath, prefix = "") {
17
20
  let dirents;
18
21
  try {
19
22
  dirents = fs.readdirSync(dirPath, { withFileTypes: true });
@@ -23,42 +26,46 @@ function walkDirectory(dirPath, prefix = "") {
23
26
  if (code === "ENOTDIR") throw new Error(`Not a directory: ${dirPath}`);
24
27
  throw err;
25
28
  }
26
- const entries = [];
27
29
  for (const entry of dirents) {
28
30
  const fullPath = path.join(dirPath, entry.name);
29
31
  const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
30
32
  if (entry.isDirectory()) {
31
- entries.push(...walkDirectory(fullPath, relativePath));
33
+ yield* walkDirectoryLazy(fullPath, relativePath);
32
34
  } else if (entry.isFile()) {
33
- entries.push({ path: relativePath, content: fs.readFileSync(fullPath) });
35
+ yield { path: relativePath, absolutePath: fullPath };
34
36
  }
35
37
  }
36
- return entries;
37
38
  }
38
39
  async function collectBytes(iter) {
39
40
  const parts = [];
41
+ let totalLength = 0;
40
42
  for await (const chunk of iter) {
41
43
  parts.push(chunk);
44
+ totalLength += chunk.length;
42
45
  }
43
- const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
44
46
  const result = new Uint8Array(totalLength);
45
47
  let offset = 0;
46
- for (const part of parts) {
48
+ for (let i = 0; i < parts.length; i++) {
49
+ const part = parts[i];
47
50
  result.set(part, offset);
48
51
  offset += part.length;
52
+ parts[i] = void 0;
49
53
  }
50
54
  return result;
51
55
  }
52
56
  async function merkleizeJS(directoryPath) {
53
57
  console.log(` Merkleizing (JS): ${directoryPath}`);
54
- const files = walkDirectory(directoryPath);
55
58
  const blockstore = new CidPreservingBlockstore();
56
- const source = files.map((file) => ({
57
- path: file.path,
58
- content: (async function* () {
59
- yield file.content;
60
- })()
61
- }));
59
+ const source = (function* () {
60
+ for (const file of walkDirectoryLazy(directoryPath)) {
61
+ yield {
62
+ path: file.path,
63
+ content: (async function* () {
64
+ yield fs.readFileSync(file.absolutePath);
65
+ })()
66
+ };
67
+ }
68
+ })();
62
69
  let rootCid;
63
70
  for await (const entry of importer(source, blockstore, {
64
71
  cidVersion: 1,
@@ -77,6 +84,7 @@ async function merkleizeJS(directoryPath) {
77
84
  }
78
85
  await writer.close();
79
86
  const carBytes = await collectPromise;
87
+ blockstore.clear();
80
88
  console.log(` CAR (JS): ${(carBytes.length / 1024 / 1024).toFixed(2)} MB`);
81
89
  return { carBytes, cid: rootCid.toString() };
82
90
  }