bulletin-deploy 0.5.1 → 0.6.0-rc.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
@@ -6,11 +6,17 @@ Deploy static sites and apps to the Polkadot Triangle network with decentralized
6
6
 
7
7
  ```bash
8
8
  npm install -g bulletin-deploy
9
- # Build your app (e.g. npm run build)
10
- bulletin-deploy ./dist my-app00.dot
9
+ bulletin-deploy my-app00.dot
11
10
  ```
12
11
 
13
- Your site is live at `https://my-app00.dot.li`
12
+ bulletin-deploy auto-detects your framework, runs the build, and deploys. Your site is live at `https://my-app00.dot.li`
13
+
14
+ You can also deploy a pre-built directory:
15
+
16
+ ```bash
17
+ npm run build
18
+ bulletin-deploy ./dist my-app00.dot
19
+ ```
14
20
 
15
21
  ## Prerequisites
16
22
 
@@ -32,35 +38,58 @@ ipfs init
32
38
  ## CLI Usage
33
39
 
34
40
  ```bash
35
- bulletin-deploy <build-dir> <domain.dot>
41
+ bulletin-deploy <domain.dot> # Auto-detect, build, deploy
42
+ bulletin-deploy <build-dir> <domain.dot> # Deploy pre-built directory
43
+ bulletin-deploy --build-only # Build only, print output dir
44
+ bulletin-deploy --dry-run <domain.dot> # Show upload plan without deploying
36
45
  ```
37
46
 
38
- Examples:
47
+ ### Options
39
48
 
40
- ```bash
41
- # Basic deploy
42
- bulletin-deploy ./dist my-app00.dot
43
-
44
- # Custom RPC endpoint
45
- bulletin-deploy --rpc wss://custom-bulletin.example.com ./dist my-app00.dot
49
+ ```
50
+ Build:
51
+ --build-cmd "..." Override detected build command
52
+ --build-dir ./out Override detected output directory
53
+ --no-build Skip build (require build dir as first arg)
54
+ --build-only Build only, print output dir to stdout
55
+ --dry-run Show what would be uploaded without deploying
56
+ --print-cache-paths Print detected cache paths for CI caching
57
+
58
+ Deploy:
59
+ --rpc wss://... Bulletin RPC (or set BULLETIN_RPC env var)
60
+ --help Show help
46
61
  ```
47
62
 
48
- ### All options
63
+ ### Build detection order
49
64
 
50
- ```
51
- Options:
52
- --rpc wss://... Bulletin RPC (or set BULLETIN_RPC env var)
53
- --help Show help
54
- ```
65
+ bulletin-deploy auto-detects your project setup:
66
+
67
+ 1. **Package manager**: `pnpm-lock.yaml` pnpm, `bun.lockb`/`bun.lock` → bun, `yarn.lock` → yarn, `package-lock.json` → npm
68
+ 2. **Build command**: `Makefile` with `build` target → `make build`, otherwise `package.json` `build` script → `<pm> run build`
69
+ 3. **Output directory**: framework config (`vite.config.*` → `dist/`, `next.config.*` → `out/`), or probes `dist/`, `out/`, `build/`
70
+
71
+ ### Exit codes
72
+
73
+ | Code | Meaning | Retryable |
74
+ |------|---------|-----------|
75
+ | 0 | Success | — |
76
+ | 1 | Deploy failure (transient) | Yes |
77
+ | 2 | Build failure | No |
78
+ | 3 | Configuration error | No |
55
79
 
56
80
  ## GitHub Actions
57
81
 
58
82
  1. Copy `workflows/deploy-on-pr.yml` to your repo's `.github/workflows/` directory
59
- 2. Customize the **Build** step for your framework (Vite, Next.js, etc.)
60
- 3. Push and watch the deploy
83
+ 2. Add your `DOTNS_MNEMONIC` secret in repo settings
84
+ 3. Push and watch the deploy — no other configuration needed
61
85
 
62
86
  The template workflow:
63
- - Deploys on push to main and on PRs
87
+ - Deploys on push to main and on PRs, with `workflow_dispatch` support
88
+ - Auto-detects your framework, package manager, and build output directory
89
+ - Uses three cache layers to speed up repeat deploys:
90
+ - **Node modules cache** via `actions/setup-node` with `cache: 'npm'`
91
+ - **Build artifacts cache** keyed on lockfile hash (paths detected by `--print-cache-paths`)
92
+ - **Deploy manifest cache** (`.bulletin-deploy-cache`) keyed on branch + SHA for incremental uploads
64
93
  - Uses `nick-fields/retry@v3` for automatic retries on transient failures
65
94
  - Posts a comment on PRs with the live URL
66
95
  - Generates domain names as `<repo>-<branch>00.dot`
@@ -85,9 +114,9 @@ console.log(result.cid, result.domainName);
85
114
  ## How It Works
86
115
 
87
116
  ```
88
- Build output ──> IPFS merkleize ──> CAR file ──> Chunk upload ──> DotNS
89
- ./dist ipfs add .car Bulletin Asset Hub
90
- Storage Registry
117
+ Source ──> Build ──> Build output ──> IPFS merkleize ──> Classify ──> Upload delta ──> DotNS ──> Registry
118
+ code auto ./dist ipfs add manifest Bulletin Asset Hub CDM
119
+ detect compare Storage Registry
91
120
  ```
92
121
 
93
122
  1. **Merkleize** your build directory with IPFS to produce a content-addressed CAR file
@@ -111,6 +140,16 @@ Instead of using a single account for all storage transactions, bulletin-deploy
111
140
 
112
141
  When a pool account's authorization drops below thresholds (50 transactions or 50MB), bulletin-deploy automatically tops it up by submitting an `authorize_account` transaction from Alice.
113
142
 
143
+ ## Incremental Upload
144
+
145
+ On repeat deploys, bulletin-deploy only uploads content that changed. A deploy manifest (`.bulletin-deploy-cache`) tracks which files and chunks are already on-chain.
146
+
147
+ - **First deploy**: uploads everything, writes the manifest
148
+ - **Subsequent deploys**: compares file CIDs against the manifest, uploads only new/changed files
149
+ - **Typical savings**: 80-95% reduction in upload size and transactions
150
+
151
+ Use `--dry-run` to preview what would be uploaded before deploying.
152
+
114
153
  ## Telemetry
115
154
 
116
155
  Sentry telemetry is enabled by default for deploy observability. Set `BULLETIN_DEPLOY_TELEMETRY=0` to disable.
@@ -1,28 +1,48 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { deploy } from "../src/deploy.js";
4
- import { bootstrapPool } from "../src/pool.js";
3
+ // build.js and manifest.js are lightweight (no @dotdm/cdm dependency),
4
+ // so we can import them eagerly. deploy.js and pool.js are loaded lazily
5
+ // so that --help and --print-cache-paths work without @dotdm/cdm installed.
6
+ import { detectFramework, detectCachePaths } from "../dist/build.js";
5
7
  import * as fs from "fs";
8
+ import * as path from "path";
9
+ import * as os from "os";
6
10
 
7
11
  const args = process.argv.slice(2);
8
12
 
9
13
  const flags = {};
10
14
  const positional = [];
11
15
  for (let i = 0; i < args.length; i++) {
12
- if (args[i] === "--bootstrap") { flags.bootstrap = true; }
13
- else if (args[i] === "--pool-size") { flags.poolSize = parseInt(args[++i], 10); }
14
- else if (args[i] === "--mnemonic") { flags.mnemonic = args[++i]; }
15
- else if (args[i] === "--rpc") { flags.rpc = args[++i]; }
16
- else if (args[i] === "--help" || args[i] === "-h") { flags.help = true; }
17
- else { positional.push(args[i]); }
16
+ const arg = args[i];
17
+ if (arg === "--bootstrap") { flags.bootstrap = true; }
18
+ else if (arg === "--pool-size") { flags.poolSize = parseInt(args[++i], 10); }
19
+ else if (arg === "--mnemonic") { flags.mnemonic = args[++i]; }
20
+ else if (arg === "--rpc") { flags.rpc = args[++i]; }
21
+ else if (arg === "--build-cmd") { flags.buildCmd = args[++i]; }
22
+ else if (arg === "--build-dir") { flags.buildDir = args[++i]; }
23
+ else if (arg === "--no-build") { flags.noBuild = true; }
24
+ else if (arg === "--build-only") { flags.buildOnly = true; }
25
+ else if (arg === "--print-cache-paths") { flags.printCachePaths = true; }
26
+ else if (arg === "--dry-run") { flags.dryRun = true; }
27
+ else if (arg === "--registry") { flags.registry = true; }
28
+ else if (arg === "--help" || arg === "-h") { flags.help = true; }
29
+ else { positional.push(arg); }
18
30
  }
19
31
 
20
- if (flags.help || (positional.length === 0 && !flags.bootstrap)) {
32
+ if (flags.help || (positional.length === 0 && !flags.bootstrap && !flags.printCachePaths && !flags.buildOnly)) {
21
33
  console.log(`Usage:
22
- bulletin-deploy <build-dir> <domain.dot> Deploy an app
34
+ bulletin-deploy <domain.dot> Auto-build and deploy
35
+ bulletin-deploy <build-dir> <domain.dot> Deploy a pre-built directory
23
36
  bulletin-deploy --bootstrap Initialize pool accounts
24
37
 
25
38
  Options:
39
+ --build-cmd "..." Override build command
40
+ --build-dir ./out Override output directory
41
+ --no-build Skip the build step (use positional arg as build dir)
42
+ --build-only Build only, print output dir to stdout, then exit
43
+ --print-cache-paths Print detected cache paths and exit
44
+ --dry-run Show upload plan without deploying
45
+ --registry Publish to playground registry (only needed on metadata change)
26
46
  --mnemonic "..." DotNS owner mnemonic (or set MNEMONIC env var)
27
47
  --rpc wss://... Bulletin RPC (or set BULLETIN_RPC env var)
28
48
  --pool-size N Number of pool accounts (default: 10)
@@ -30,33 +50,161 @@ Options:
30
50
  process.exit(0);
31
51
  }
32
52
 
33
- if (flags.rpc) process.env.BULLETIN_RPC = flags.rpc;
53
+ if (flags.rpc) process.env.BULLETIN_RPC = flags.rpc;
34
54
  if (flags.poolSize) process.env.BULLETIN_POOL_SIZE = String(flags.poolSize);
55
+ if (flags.mnemonic) process.env.MNEMONIC = flags.mnemonic;
56
+
57
+ const projectDir = process.cwd();
58
+
59
+ // --print-cache-paths: detect and print, then exit (no heavy deps needed)
60
+ if (flags.printCachePaths) {
61
+ const framework = detectFramework(projectDir);
62
+ const cachePaths = detectCachePaths(framework);
63
+ for (const p of cachePaths) {
64
+ console.log(p);
65
+ }
66
+ process.exit(0);
67
+ }
68
+
69
+ // Lazy-load heavy modules that require @dotdm/cdm; run in parallel
70
+ const [
71
+ { deploy, merkleize, chunk, measureBuildOutput },
72
+ { runBuild },
73
+ { readManifest, classifyWithManifest, classifyFile, formatDryRunReport },
74
+ { bootstrapPool },
75
+ ] = await Promise.all([
76
+ import("../dist/deploy.js"),
77
+ import("../dist/build.js"),
78
+ import("../dist/manifest.js"),
79
+ import("../dist/pool.js"),
80
+ ]);
35
81
 
36
82
  try {
37
83
  if (flags.bootstrap) {
38
84
  const rpc = process.env.BULLETIN_RPC || "wss://paseo-bulletin-rpc.polkadot.io";
39
85
  const poolSize = parseInt(process.env.BULLETIN_POOL_SIZE || "10", 10);
40
86
  await bootstrapPool(rpc, poolSize);
87
+ process.exit(0);
88
+ }
89
+
90
+ // --build-only: run build, print output dir, exit
91
+ if (flags.buildOnly) {
92
+ const result = await runBuild(projectDir, {
93
+ buildCmd: flags.buildCmd,
94
+ buildDir: flags.buildDir,
95
+ });
96
+ console.log(path.resolve(projectDir, result.outputDir));
97
+ process.exit(0);
98
+ }
99
+
100
+ // Determine build dir and domain from positional args
101
+ let buildDir;
102
+ let domain;
103
+
104
+ const [firstArg, secondArg] = positional;
105
+
106
+ if (firstArg && fs.existsSync(firstArg)) {
107
+ // Backward-compat: first arg is an existing path — treat as build dir
108
+ buildDir = firstArg;
109
+ domain = secondArg;
41
110
  } else {
42
- const [buildDir, domain] = positional;
43
- if (!buildDir) { console.error("Error: build directory required"); process.exit(1); }
44
- if (!domain) { console.error("Error: domain required (e.g. my-app.dot)"); process.exit(1); }
45
- if (!fs.existsSync(buildDir)) { console.error(`Error: ${buildDir} does not exist`); process.exit(1); }
111
+ domain = firstArg;
112
+ buildDir = null;
113
+ }
114
+
115
+ if (!domain) {
116
+ console.error("Error: domain required (e.g. my-app.dot)");
117
+ process.exit(3);
118
+ }
119
+
120
+ // Build step (unless --no-build or buildDir already provided from positional)
121
+ if (!buildDir && !flags.noBuild) {
122
+ console.log("\nBuilding...");
123
+ const buildResult = await runBuild(projectDir, {
124
+ buildCmd: flags.buildCmd,
125
+ buildDir: flags.buildDir,
126
+ });
127
+ buildDir = path.resolve(projectDir, buildResult.outputDir);
128
+ console.log(` Output: ${buildDir} (${buildResult.durationMs}ms)`);
129
+ } else if (flags.noBuild && !buildDir) {
130
+ if (flags.buildDir) {
131
+ buildDir = flags.buildDir;
132
+ } else {
133
+ console.error("Error: --no-build requires a build directory (pass as first arg or --build-dir)");
134
+ process.exit(3);
135
+ }
136
+ }
137
+
138
+ if (!fs.existsSync(buildDir)) {
139
+ console.error(`Error: build directory not found: ${buildDir}`);
140
+ process.exit(3);
141
+ }
142
+
143
+ // --dry-run: show upload plan without deploying
144
+ if (flags.dryRun) {
145
+ const tmpDir = os.tmpdir();
146
+ const carPath = path.join(tmpDir, `bulletin-dry-run-${Date.now()}.car`);
147
+
148
+ try {
149
+ const { cid } = await merkleize(buildDir, carPath);
150
+
151
+ const outputInfo = measureBuildOutput(buildDir);
152
+ const currentFiles = outputInfo.files.map(f => ({ path: f.path, cid: `${cid}:${f.path}`, size: f.size }));
153
+
154
+ const manifestPath = path.join(projectDir, ".bulletin-manifest.json");
155
+ const manifest = readManifest(manifestPath);
46
156
 
47
- const result = await deploy(buildDir, domain);
157
+ let classification;
158
+ if (manifest) {
159
+ classification = classifyWithManifest(currentFiles, manifest);
160
+ } else {
161
+ const stable = currentFiles.filter(f => classifyFile(f.path) === "stable");
162
+ const volatile = currentFiles
163
+ .filter(f => classifyFile(f.path) === "volatile")
164
+ .map(f => ({ ...f, previousCid: null }));
165
+ classification = { stable, volatile, hitRate: stable.length / (currentFiles.length || 1), source: "heuristic" };
166
+ }
48
167
 
49
- const output = process.env.GITHUB_OUTPUT;
50
- if (output) {
51
- fs.appendFileSync(output, `cid=${result.cid}\n`);
52
- fs.appendFileSync(output, `domain=${result.domainName}\n`);
168
+ const carBuffer = fs.readFileSync(carPath);
169
+ const carChunks = chunk(carBuffer);
170
+ const totalChunks = carChunks.length;
171
+ const newChunks = classification.volatile.length > 0 ? carChunks.length : 0;
172
+
173
+ const meta = {
174
+ manifestPath: manifest ? manifestPath : null,
175
+ lastDeploy: manifest ? manifest.deployed_at : null,
176
+ totalChunks,
177
+ newChunks,
178
+ };
179
+
180
+ const report = formatDryRunReport(classification, meta);
181
+ console.log(report);
182
+ } finally {
183
+ try { fs.unlinkSync(carPath); } catch { /* ignore */ }
53
184
  }
185
+ process.exit(0);
186
+ }
187
+
188
+ const result = await deploy(buildDir, domain, { registry: flags.registry ?? false });
54
189
 
55
- console.log(`CID: ${result.cid}`);
56
- console.log(`Domain: ${result.domainName}`);
190
+ const output = process.env.GITHUB_OUTPUT;
191
+ if (output) {
192
+ fs.appendFileSync(output, `cid=${result.cid}\n`);
193
+ fs.appendFileSync(output, `domain=${result.domainName}\n`);
57
194
  }
195
+
196
+ console.log(`CID: ${result.cid}`);
197
+ console.log(`Domain: ${result.domainName}`);
58
198
  process.exit(0);
199
+
59
200
  } catch (error) {
60
- console.error("Deployment failed:", error.message);
61
- process.exit(1);
201
+ const exitCode = error.exitCode ?? 1;
202
+ if (exitCode === 2) {
203
+ console.error("Build failed:", error.message);
204
+ } else if (exitCode === 3) {
205
+ console.error("Config error:", error.message);
206
+ } else {
207
+ console.error("Deployment failed:", error.message);
208
+ }
209
+ process.exit(exitCode);
62
210
  }
package/cdm.json ADDED
@@ -0,0 +1,118 @@
1
+ {
2
+ "targets": {
3
+ "acc2c3b5e912b762": {
4
+ "asset-hub": "wss://asset-hub-paseo-rpc.n.dwellir.com",
5
+ "bulletin": "https://paseo-ipfs.polkadot.io/ipfs"
6
+ }
7
+ },
8
+ "dependencies": {
9
+ "acc2c3b5e912b762": {
10
+ "@example/playground-registry": "latest"
11
+ }
12
+ },
13
+ "contracts": {
14
+ "acc2c3b5e912b762": {
15
+ "@example/playground-registry": {
16
+ "version": 2,
17
+ "address": "0x7D97a3E87c0C2fe921E471D076b95975886CEAA8",
18
+ "abi": [
19
+ {
20
+ "type": "constructor",
21
+ "inputs": [],
22
+ "stateMutability": "nonpayable"
23
+ },
24
+ {
25
+ "type": "function",
26
+ "name": "publish",
27
+ "inputs": [
28
+ {
29
+ "name": "domain",
30
+ "type": "string"
31
+ },
32
+ {
33
+ "name": "metadata_uri",
34
+ "type": "string"
35
+ }
36
+ ],
37
+ "outputs": [],
38
+ "stateMutability": "nonpayable"
39
+ },
40
+ {
41
+ "type": "function",
42
+ "name": "getMetadataUri",
43
+ "inputs": [
44
+ {
45
+ "name": "domain",
46
+ "type": "string"
47
+ }
48
+ ],
49
+ "outputs": [
50
+ {
51
+ "name": "",
52
+ "type": "tuple",
53
+ "components": [
54
+ {
55
+ "name": "isSome",
56
+ "type": "bool"
57
+ },
58
+ {
59
+ "name": "value",
60
+ "type": "string"
61
+ }
62
+ ]
63
+ }
64
+ ],
65
+ "stateMutability": "view"
66
+ },
67
+ {
68
+ "type": "function",
69
+ "name": "getDomainAt",
70
+ "inputs": [
71
+ {
72
+ "name": "index",
73
+ "type": "uint32"
74
+ }
75
+ ],
76
+ "outputs": [
77
+ {
78
+ "name": "",
79
+ "type": "string"
80
+ }
81
+ ],
82
+ "stateMutability": "view"
83
+ },
84
+ {
85
+ "type": "function",
86
+ "name": "getOwner",
87
+ "inputs": [
88
+ {
89
+ "name": "domain",
90
+ "type": "string"
91
+ }
92
+ ],
93
+ "outputs": [
94
+ {
95
+ "name": "",
96
+ "type": "address"
97
+ }
98
+ ],
99
+ "stateMutability": "view"
100
+ },
101
+ {
102
+ "type": "function",
103
+ "name": "getAppCount",
104
+ "inputs": [],
105
+ "outputs": [
106
+ {
107
+ "name": "",
108
+ "type": "uint32"
109
+ }
110
+ ],
111
+ "stateMutability": "view"
112
+ }
113
+ ],
114
+ "metadataCid": "bafk2bzacecrjrmrgumcww7oc23rllfscbxpd2lxc6p2svb26c6e5tjvmr565s"
115
+ }
116
+ }
117
+ }
118
+ }
package/dist/build.js ADDED
@@ -0,0 +1,25 @@
1
+ import {
2
+ BuildError,
3
+ ConfigError,
4
+ detectBuildCommand,
5
+ detectCachePaths,
6
+ detectFramework,
7
+ detectPackageManager,
8
+ probeOutputDir,
9
+ runBuild,
10
+ scanMakefilesForOutputDir,
11
+ scanWorkflowsForOutputDir
12
+ } from "./chunk-RRQB6BPV.js";
13
+ import "./chunk-QGM4M3NI.js";
14
+ export {
15
+ BuildError,
16
+ ConfigError,
17
+ detectBuildCommand,
18
+ detectCachePaths,
19
+ detectFramework,
20
+ detectPackageManager,
21
+ probeOutputDir,
22
+ runBuild,
23
+ scanMakefilesForOutputDir,
24
+ scanWorkflowsForOutputDir
25
+ };