Skip to content

feat(cli): add --car flag to bulletin upload#36

Closed
BigTava wants to merge 7 commits intomainfrom
feat/deploy-use-car
Closed

feat(cli): add --car flag to bulletin upload#36
BigTava wants to merge 7 commits intomainfrom
feat/deploy-use-car

Conversation

@BigTava
Copy link
Copy Markdown
Contributor

@BigTava BigTava commented Mar 17, 2026

Description

Adds a --use-car flag to dotns bulletin upload for directory uploads. When set, the CLI:

  1. Merkleizes the directory using the local IPFS CLI (ipfs add -r --cid-version=1 --raw-leaves)
  2. Exports a CAR file (ipfs dag export)
  3. Uploads the CAR as a single chunked file to Bulletin

This replaces the three separate Setup IPFS Kubo / Initialize IPFS / Merkleize into CAR file steps that were previously only available in the deploy workflow (PR #27). Now callers can trigger the same behaviour directly from the CLI:

dotns bulletin upload ./dist --use-car --parallel

The IPFS repo is initialised automatically (ipfs init) if not already present. The flag is a no-op for file uploads (errors with a clear message).

Why CAR upload?

The default mode merkleizes in-memory and stores each IPFS block individually. CAR-based upload writes fewer, larger transactions, which can be faster for large sites and is required for compatibility with gateways that expect a packed DAG (e.g. the Polkadot Triangle gateway).

Type

  • Bug fix
  • Feature
  • Breaking change
  • Documentation
  • Chore

Package

  • @dotns/cli
  • Root/monorepo
  • Documentation

Related Issues

Closes #28 (no progress feedback during upload — CAR upload makes this more noticeable)

Fixes

N/A

Checklist

Code

  • Follows project style
  • bun run lint passes
  • bun run format passes
  • bun run typecheck passes

Documentation

  • README updated if needed
  • Types updated if needed

Breaking Changes

  • No breaking changes
  • Breaking changes documented below

Testing

How to test:

  1. Upload a directory without the flag — verify existing block-by-block behaviour is unchanged
    dotns bulletin upload ./examples/deploy/site
  2. Upload a directory with --use-car — verify IPFS CLI is invoked, a temp CAR file is created, and upload succeeds
    dotns bulletin upload ./examples/deploy/site --use-car --parallel
  3. Upload a single file with --use-car — verify it fails with a clear error message
    dotns bulletin upload ./examples/deploy/site/index.html --use-car
    # Expected: Error: --use-car is only supported for directory uploads
  4. Verify the returned CID matches the IPFS CID of the directory:
    ipfs add -Q -r --cid-version=1 --raw-leaves ./examples/deploy/site
    # Should match the CID printed by dotns bulletin upload --use-car

Notes

  • Requires IPFS Kubo to be installed and on $PATH (e.g. via brew install ipfs or the deploy workflow's ipfs/download-ipfs-distribution-action)
  • The temp CAR file is written to os.tmpdir() and deleted after upload
  • The workflow (.github/workflows/deploy.yml) use-car: true input now delegates merkleization to this CLI flag instead of the separate shell steps — see PR feat: add use-car option to deploy workflow #27
  • An example workflow using use-car is included at examples/deploy/site/workflows/car-upload.yml

closes https://github.com/paritytech/dotns/issues/95

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 17, 2026

CI Summary

Check Result
Lint Passed
Format Passed
Typecheck Passed
Build Passed
Release Passed
Deploy Example Passed
PR Title Passed
Labels Passed

Release - Passed

Test this PR

Download artifact (GitHub CLI required):

gh run download 23238233460 -n cli-preview-0.0.0-pr.36 -R paritytech/dotns-sdk

Install globally:

Package Manager Command
npm npm install -g ./dotns-cli-0.0.0-pr.36.tgz
yarn yarn global add ./dotns-cli-0.0.0-pr.36.tgz
bun (macOS/Linux) bun add -g "$(pwd)/dotns-cli-0.0.0-pr.36.tgz"
bun (Windows) bun add -g "$PWD\dotns-cli-0.0.0-pr.36.tgz"

Verify:

dotns --help
Deploy Example — Passed
Stage Status
✓ Site validation Site validated
✓ Deploy Deployed (cached)
Property Value
Domain pr36.dotns-example-site.dot
CID bafybeibkahy65wntair6iba7fal3oky4fnznifzuvnb4sz66qmoldzk7ti (cached)
URL https://pr36.dotns-example-site.paseo.li

View run

Labels

pkg: cli, scope: bulletin, type: docs, type: test

Adds `--use-car` to `dotns bulletin upload` so the CLI can merkleize
a directory with the IPFS CLI, export a CAR file, and upload it to
Bulletin — replacing the three separate workflow steps (Setup IPFS Kubo,
Initialize IPFS, Merkleize into CAR file).
@BigTava BigTava force-pushed the feat/deploy-use-car branch from 49d6456 to 776a309 Compare March 17, 2026 17:04
Copy link
Copy Markdown
Collaborator

@andrew-ifrita andrew-ifrita left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments regarding only the code changes. Most are small nits. A small refactor would be nice where mentioned just to help with testability but that isn't required.

The main missing piece is that the --use-car path seems to be missing in the test suite.

On the more subjective side, --use-car may not be the most intuitive, athough I have mixed feelings about the suggestions off the top of my head

  • --archive (-as-car?)
  • --bundle (-as-car?)
  • --bundled
  • --car (may be the best if we can assume familiarity with IPFS, but may not be the best assumption to make)

Comment thread packages/cli/src/bulletin/ipfs.ts
Comment thread packages/cli/src/bulletin/ipfs.ts
Comment thread packages/cli/src/cli/commands/bulletin.ts Outdated
Comment thread packages/cli/src/cli/commands/bulletin.ts
@andrew-ifrita
Copy link
Copy Markdown
Collaborator

@BigTava Outside of the code specific changes, the CAR stuff makes complete sense in transit, but do you realize that we are referencing the CAR bytes directly with the CID instead of the unpacked root CID? I would normally assume this is a mistake, but I remember some old CAR handling code in the past.

My assumption is that we just want to do something like this with out script

ipfs dag import archive.car

I don't have a ton of ipfs via js experience but claude suggests

import { CarReader } from '@ipld/car';
                                                                                      
// ... after exportCarFile(ipfsCid, tempCarPath) ...                                  
const carBytes = await filesystem.readFile(tempCarPath);                              
const reader = await CarReader.fromBytes(new Uint8Array(carBytes));                   
const roots = await reader.getRoots();                                                
const rootCid = roots[0].toString(); // should match ipfsCid                          
                                                                                      
const blocks = [];                                                                    
for await (const block of reader.blocks()) {                                          
  blocks.push(block);                                                                 
}                                                                                     
                                                                                      
// Upload each block individually (reuse existing wave logic from storeDirectory)     
for (const block of blocks) {
  await storeBlockToBulletin({                                                        
    rpc: bulletinRpc,                                                                 
    signer,                                                                           
    contentBytes: new Uint8Array(block.bytes),                                        
    contentCid: block.cid.toString(),                                                 
    codecValue: block.cid.code,                                                       
    hashCodeValue: block.cid.multihash.code,                                          
    client: sharedClient,                                                             
  });                                                                                 
}                                                                                     
                
return { cid: rootCid, ipfsCid: rootCid, size: totalSize }; 

Something like this will give you the benefits of CAR when sending without any special handling of Content Addressable aRchives for reading.

storageCid = await uploadSingleBlock(bulletinRpc, context.signer, carBytes);
}

return { cid: storageCid, ipfsCid, size: carBytes.length };
Copy link
Copy Markdown
Collaborator

@andrew-ifrita andrew-ifrita Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will return the cid of the car blob which is non-standard for anyone accessing the data as CAR is meant for transport.

We likely want something like

const reader = await CarReader.fromBytes(new Uint8Array(carBytes));                   
const roots = await reader.getRoots();                                                
const rootCid = roots[0].toString(); // should match ipfsCid

assert.isEqual(ipfsCid, rootCid);

return { cid: rootCid, ipfsCid: rootCid, size: totalSize };

edit: better context in this comment #36 (comment)

Copy link
Copy Markdown
Contributor Author

@BigTava BigTava Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CAR bytes are stored as chunked content with a DAG-PB root via uploadChunkedBlocks(). The cid field returns the DAG-PB root (the Bulletin storage handle the gateway resolves), and ipfsCid carries the IPFS directory root for reference.

Copy link
Copy Markdown
Collaborator

@andrew-ifrita andrew-ifrita Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CAR bytes are stored as chunked content with a DAG-PB root

This is what I see as the problem. The CAR is meant for transport. I'll throw an example together. I may just be misreading the code and the only issue is the CID that is output

ipfsCid carries the IPFS directory root for reference.

This part is a little misleading in the current code in master tbh. ipfsCid is only used in getPreviewUrl() by the looks of it. Which is probably the root of the issue

Aside: We should probably consolidate ipfsCid and cid because currently (outside this PR) every cid == ipfsCid. That is outside the scope here and I can follow up with it after. (edit: added issue here https://github.com/paritytech/dotns/issues/97)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what you were doing before. As I far as I went, querying gateway/<cid> works. gateway/<storageCid> doesn't work.

Copy link
Copy Markdown
Collaborator

@andrew-ifrita andrew-ifrita Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My example is failing due to possible bulletin chain issues

dotns-sdk/packages/cli/src/bulletin$ bun run dev -- bulletin upload ~/dev/andrew/moonwalker/ --car --parallel
$ bun run src/cli/index.ts bulletin upload /home/andrew/dev/andrew/moonwalker/ --car --parallel

════════════════════════════════════════
          dotns developer CLI           
════════════════════════════════════════

✔ Directory validated
• Connecting RPC wss://paseo-bulletin-rpc.polkadot.io
✓ Connecting RPC wss://paseo-bulletin-rpc.polkadot.io
• Resolving account
✓ Resolving account
• Loading keypair
✓ Loading keypair

📋 Configuration

  RPC:       wss://paseo-bulletin-rpc.polkadot.io
  Chain:     Bulletin
  Substrate: 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV
  Auth:      default
  Account:   default
  Balance:   0.000000000000

▶ Bulletin Upload
  path:     /home/andrew/dev/andrew/moonwalker
  rpc:      wss://paseo-bulletin-rpc.polkadot.io
  mode:     car (ipfs cli)
✔ Merkleized: bafybeictaikgnxa6aiumef2rfheygvdz2dgmh7sb6f7dumkizodzqdgbua
✔ CAR exported: 3.23 MB
  chunks:   2
✖ Upload failed

✗ Error: Descriptor for tx TransactionStorage.store_with_cid_config does not exist

error: script "dev" exited with code 1

which makes this hard to test out at the moment. I don't believe this is related to this PR

edit: 🤦 I forgot

bun run prepare

which explains the issues.

Copy link
Copy Markdown
Collaborator

@andrew-ifrita andrew-ifrita Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok here is an example (sorry for delays, had some calls I had to deal with)

bun run dev -- bulletin upload ~/dev/andrew/moonwalker/ --car --parallel
$ bun run src/cli/index.ts bulletin upload /home/andrew/dev/andrew/moonwalker/ --car --parallel

════════════════════════════════════════
          dotns developer CLI           
════════════════════════════════════════

✔ Directory validated
• Connecting RPC wss://paseo-bulletin-rpc.polkadot.io
✓ Connecting RPC wss://paseo-bulletin-rpc.polkadot.io
• Resolving account
✓ Resolving account
• Loading keypair
✓ Loading keypair

📋 Configuration

  RPC:       wss://paseo-bulletin-rpc.polkadot.io
  Chain:     Bulletin
  Substrate: 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV
  Auth:      default
  Account:   default
  Balance:   0.000000000000

▶ Bulletin Upload
  path:     /home/andrew/dev/andrew/moonwalker
  rpc:      wss://paseo-bulletin-rpc.polkadot.io
  mode:     car (ipfs cli)
✔ Merkleized: bafybeictaikgnxa6aiumef2rfheygvdz2dgmh7sb6f7dumkizodzqdgbua
✔ CAR exported: 3.23 MB
  chunks:   2
✔ Stored

  cid:         bafybeibwo72gsgp4qzy7p2gypyxu2oofhnnikcmg74wpys7tchie2ylhqm
  ipfs-cid:    bafybeictaikgnxa6aiumef2rfheygvdz2dgmh7sb6f7dumkizodzqdgbua
  preview:     http://dotns.paseo.li/#/preview/YmFmeWJlaWN0YWlrZ254YTZhaXVtZWYycmZoZXlndmR6MmRnbWg3c2I2ZjdkdW1raXpvZHpxZGdidWE

✓ Upload Complete

Resulting in 2 output CIDs

cid: bafybeibwo72gsgp4qzy7p2gypyxu2oofhnnikcmg74wpys7tchie2ylhqm
ipfs-cid: bafybeictaikgnxa6aiumef2rfheygvdz2dgmh7sb6f7dumkizodzqdgbua

So it works, but cid is effectively worthless. I would simply replace

return { cid: storageCid, ipfsCid, size: carBytes.length };

with

return { cid: ipfsCid, ipfsCid, size: carBytes.length };

and we should be good.


outside the scope of the PR, a nice touch would also be only outputting the cid when piping, allowing easy usage in bash

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the ipfsCid only works when you first fetch the storageCid. Only then the ipfsCid is cached in the gateway.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a PR to merge into this one: #43

Hopefully that clears things up a little

Copy link
Copy Markdown
Contributor Author

@BigTava BigTava Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you change anything in the build so the CID is different? Also, did you try to fetch directly from the bulletin nodes via Helia? Did you try to fetch from the only ipfs gateway that matters - https://paseo-ipfs.polkadot.io/ipfs? Also this is not working for me https://dotns.paseo.li/#/preview/YmFmeWJlaWdreGs1eG92cmlzdzVya2NvZ3J3ZzJ2M3FzcHlscnlsem9uYnl0Y20zeHhkdnMzeGJud2k.

Copy link
Copy Markdown
Collaborator

@andrew-ifrita andrew-ifrita Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the ipfsCid only works when you first fetch the storageCid. Only then the ipfsCid is cached in the gateway.

With the linked changes in #43, we are storing each block individually with its original CID, that two-step process shouldn't be necessary anymore. The ipfsCid blocks are stored directly on Bulletin.

transactions?: string;
/** Number of bytes to authorize */
bytes?: string;
/** Merkleize with IPFS CLI and upload as a CAR file instead of individual blocks */
Copy link
Copy Markdown
Member

@sphamjoli sphamjoli Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great start, We missing helper tests and integration tests here

Copy link
Copy Markdown
Contributor Author

@BigTava BigTava Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

BigTava added 3 commits March 17, 2026 23:28
- Extract CAR upload logic into storeCar() in commands/bulletin.ts,
  mirroring deploy.js storeChunkedContent: exports a CAR, stores the
  bytes as chunked content with a DAG-PB root via uploadChunkedBlocks
- Use randomUUID() for the temp CAR filename instead of Date.now()
- Add safety comment on execSync calls in ensureIpfsInitialized()
- Simplify cli/commands/bulletin.ts: useCar path is now a single call
  to storeCar(); removes previously inlined ora/fs/os/path/ipfs imports
…o --car

- ensureIpfsInitialized() now returns the resolved binary path so callers
  can pass it into merkleizeWithIpfs() and exportCarFile(), avoiding
  repeated findIpfsBinaryPath() lookups and improving testability
- merkleizeWithIpfs, merkleizeSingleFileWithIpfs, and exportCarFile each
  accept an optional ipfsBinaryPath parameter (falls back to
  findIpfsBinaryPath() for standalone callers)
- storeCar() resolves the path once via ensureIpfsInitialized() and
  passes it through both subsequent IPFS calls
- Rename --use-car flag to --car (matches --parallel, --force-chunked
  naming convention and is more concise for IPFS-familiar users)
- Unit: assert --car appears in bulletin upload --help output
- Integration: upload directory with --car, verify mode line and
  ipfs-cid field in terminal output
- Integration: --car with --json, verify cid (DAG-PB root) and
  ipfsCid (IPFS directory root) are both present
- Integration: --car on a file path surfaces a clear error message
- Extend JsonUploadResult type with optional ipfsCid field
@BigTava
Copy link
Copy Markdown
Contributor Author

BigTava commented Mar 18, 2026

@sphamjoli @andrew-ifrita Thanks for your comments.

@BigTava
Copy link
Copy Markdown
Contributor Author

BigTava commented Mar 19, 2026

@andrew-ifrita what is the status?

@andrew-ifrita andrew-ifrita changed the title feat(cli): add --use-car flag to bulletin upload feat(cli): add --car flag to bulletin upload Mar 24, 2026
@andrew-ifrita
Copy link
Copy Markdown
Collaborator

I worked out my train of thought in #43. Here is my resulting opinion #43 (comment)

If a user wants to upload a car directly, they can do it with the existing functionality after first generating the CAR and then uploading that blob. We shouldn't help the user shoot themselves in the foot by uploading a CAR blob in a way that goes against standard IPFS usage and practices.

If a user wants, nothing is preventing something like

ipfs export ./project | dotns bulletin upload

So this feature shouldn't be implemented on the CLI side and I would label this as a "good enough" solution that we are trying to avoid. A proper solution would include adding CAR upload support to Bulletin. And even a useful intermediate solution would be allowing batched transaction on bulletin, but this last option may not be feasible for technical reason (I am not a bulletin expert by any means).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants