This is the NORMATIVE contract for the dig-store .dig store format: the byte-exact
data-section blob, the capsule/generation model, per-resource crypto and merkle
commitment, and the normalized public manifest. An independent implementation that
reads or writes .dig modules MUST conform to this document. The single source of
truth in code is digstore-core (datasection, merkle, crypto, urn,
public_manifest); this spec MUST agree with it and with the ecosystem contracts in
the superproject SYSTEM.md and the user-facing protocol pages under docs.dig.net.
CLI binaries. The digstore-cli crate ships TWO binaries, dig-store and digs.
digs is a first-class alias: digs <args> behaves IDENTICALLY to dig-store <args>
(same subcommands, flags, --json, and exit codes). Both share ONE codepath
(digstore_cli::run()) and each reflects its own invoked name (arg0) in
--help/--version/completion/--help-json. Both binaries MUST be shipped together
everywhere dig-store ships (cargo-install, the universal installer, the apt .deb).
All multi-byte integers are big-endian (Chia streamable framing). The shared codec
(digstore_core::codec) frames: uN as N/8 BE bytes; String as u32 byte length
- UTF-8 bytes;
Vec<T>asu32count + eachT;Bytes32/Bytes48as raw fixed bytes;Option<T>as a1-byte tag (0=None,1=Some) then the value.
- A store is identified by its 32-byte
store_id(the on-chain CHIP-0035 DataStore launcher id). It is NOT a hash of any key. - A resource's canonical URN is
urn:dig:<chain>:<storeID>[:<rootHash>][/<resourceKey>]withchain = "chia". Key derivation uses the ROOT-INDEPENDENT form (root omitted). - The retrieval key =
SHA-256(canonical_rootless_urn), lowercase hex. It is the only store-side identifier a client sends to a CDN/RPC; the URN itself is never transmitted. - A URN with no resource key resolves to the default view
index.html(§8.5 convention).
- Public store: the per-resource AES key derives from the URN alone; anyone who knows
the
store_idand a path can derive the retrieval + decryption keys. All resources are public. - Private store: a 32-byte secret salt is additionally mixed into key derivation. Resource paths are secret — nothing may map a public name to a private resource.
- Each resource is chunked (FastCDC-style; min 16 KiB, target 64 KiB, max 256 KiB).
- Each chunk is sealed with AES-256-GCM-SIV under the per-resource content key derived
by HKDF-SHA256 (salt
b"digstore-hkdf-salt-v1", infob"digstore-aes-256-gcm-key-v1"; private stores mix the 32-byte secret salt). Chunks are stored and content-addressed as CIPHERTEXT, keyed bySHA-256(ciphertext). - The served bytes for a resource are the PLAIN ordered concatenation of its chunk ciphertexts (no length framing on the wire).
- The per-resource merkle leaf (D5) is
resource_leaf = SHA-256(concat(ordered chunk ciphertext bodies))— i.e.SHA-256over exactly the bytes served for the resource. This is the single content→leaf binding shared by the producer and the browser verifier. - A generation's merkle tree has ONE leaf per resource, ordered ascending by
static_key(the resource's retrieval key). Its root is the generation'sCurrentRoot.
- A capsule = one immutable generation =
(store_id, rootHash), canonical stringstoreId:rootHash. Eachcommitproduces one capsule. - Each generation persists a
GenerationManifest(JSON) listing the resources committed IN that generation:{ schema_version, generation_id, root, timestamp, chunks[], key_table[] }, where eachkey_tablerecord carriesresource_key,static_key,generation,chunk_indices,total_size. A generation's key table is the DELTA committed in that generation, not the cumulative file set. - RootHistory is the append-only, strictly monotonic sequence of
(generation_id, root, timestamp), oldest → newest.
The compiled .dig module embeds a self-describing data-section blob (BINDING contract D1):
magic "DIGS" (4 bytes)
version u8 = 1 (1 byte)
count u32 BE (4 bytes) number of offset rows
rows count × 10 bytes: id:u16 BE | offset:u32 BE | len:u32 BE (offset/len from byte 0)
bodies concatenated section bodies
total_len = max(offset + len). DataView::parse validates the magic, version == 1, and
that every row lies within the blob.
| id | Section | Body | Presence |
|---|---|---|---|
| 1 | StoreId | 32 raw bytes | always |
| 2 | CurrentRoot | 32 raw bytes (current generation's merkle root, D5) | always |
| 3 | RootHistory | Vec<Bytes32> |
always |
| 4 | PublicKey | 48 raw bytes (BLS G1 publisher key) | always |
| 5 | TrustedKeys | Vec<{ [u8;48] public_key, String label }> |
always |
| 6 | Metadata | MetadataManifest (plaintext) |
always |
| 7 | AuthInfo | AuthenticationInfo |
always |
| 8 | KeyTable (D3) | u32 count, per entry static_key(32) | generation(32) | chunk_indices(Vec<u32>) | total_size(u64) |
always |
| 9 | ChunkPool (D4) | u32 count, per chunk len(u32) | ciphertext — global index order |
always |
| 10 | MerkleNodes (D5) | u32 count + count×32 raw — per-resource leaves, ascending by static_key |
always |
| 11 | Filler | unreferenced deterministic ChaCha20 padding (uniform-size obfuscation) | always; MUST be the trailing/highest-offset body |
| 12 | ChainState | on-chain anchor pointer | optional |
| 13 | PublicManifest | normalized public file set (§6) | optional (PUBLIC stores only) |
The producer emits the always-present sections in ascending id order, then the optional ChainState (12) and PublicManifest (13) BEFORE Filler, so Filler remains the highest-offset body (the uniform-size padding grows only Filler).
- Section ids are only ever added — never removed, renumbered, or repurposed. The body
layout of an existing id never changes meaning. The blob
versionstays1. - A reader looks each section up by id and ignores unknown ids. A newer writer's blob therefore parses in an older reader (which simply sees fewer sections), and a newer reader treats an absent optional section as "not present" (never an error).
- Producers keep golden
.digdata-section fixtures of each released format; a reader change MUST prove it decodes older golden fixtures unchanged.
The public manifest is the store's COMPLETE public file surface, flattened across every
published capsule: one entry per public file path, holding that path's LATEST version and
its provenance. Where the KeyTable (8) lists a single generation's resources by hashed
static_key, the public manifest exposes the human path and, for each path, which capsule +
version index hold its latest content — including files whose latest version lives in an
EARLIER capsule.
Presence. The section is embedded only for PUBLIC stores. A private store's paths are secret, so it carries NO PublicManifest section.
Normalization rule. Walk every generation oldest → newest (by generation_id). For each
path (resource key): the LATEST version is the one in the highest generation_id whose file
set includes the path; version_count is the number of generations whose file set includes
the path.
Body layout (codec framing):
schema_version : u32 BE
entries : Vec, u32 BE count, per entry:
path : String (u32 BE len + utf8 bytes)
latest_root : 32 raw bytes
generation_index : u64 BE
sha256_latest : 32 raw bytes
version_count : u32 BE
Entries are ordered ascending by path (UTF-8 byte order); the encoding is deterministic.
Field contract.
| Field | Type | Meaning |
|---|---|---|
path |
string | The public file path (resource key), e.g. index.html, assets/app.js. |
latest_root |
Bytes32 | The capsule (root) holding this path's latest version. |
generation_index |
u64 | The generation id of that latest version (the commit that last wrote the path). |
sha256_latest |
Bytes32 | SHA-256 of the latest version's content = the D5 per-resource leaf: SHA-256 over the concatenated ordered chunk ciphertext bodies of the latest version. |
version_count |
u32 | How many versions of the path exist across the whole history (generations that include it). |
schema_version starts at 1; future fields are only APPENDED, so a reader dispatches on
the version and older bodies remain readable.
JSON surface. The CLI dig-store manifest --json, the JSON-RPC dig.getManifest, and the
browser reader readPublicManifest all emit the SAME shape with the byte fields as 64-char
lowercase hex:
{
"schema_version": 1,
"entries": [
{ "path": "index.html", "latest_root": "<64-hex>", "generation_index": 1,
"sha256_latest": "<64-hex>", "version_count": 2 }
]
}- Producer —
digstore_store::build_public_manifest(generations_dir)computes the manifest from on-disk generations; the compiler (digstore-compiler) embeds it as section 13 for public stores. - Blob reader —
digstore_core::datasection::read_public_manifest(blob)returnsOption<PublicManifest>(None when absent).dig-client-wasm::readPublicManifest(blob)exposes it to the browser as JSON. - CLI —
dig-store manifest [--json]prints the normalized manifest.
- The public manifest field names, types, ordering, and byte layout MUST match §6 exactly so
hub.dig.net and dig-node reproduce it byte-for-byte (see
SYSTEM.md→ Shared contracts → "Normalized public manifest"). sha256_latestMUST equal the D5resource_leafof the latest version, so a consumer can cross-check it against the served bytes and the merkle root.- The capsule format and the additive-section rule are mirrored in
docs.dig.net/docs/protocol/capsule-format.md; all three MUST agree.
Scope note: unlike §1–8, this section is a CLI/on-chain-authority contract, not a .dig
byte-format contract — it is kept here because it is a public surface an independent
dig-store reimplementation must also expose.
dig-store authorize-origin-as-writer <origin> [--pubkey <hex>] [--dry-run] [--fee <mojos>]authorizes an origin's DIG identity as a CHIP-0035 writer delegate on the active store's on-chain singleton, using the existingdigstore_chain::singletondelegation primitive (writer_delegated_puzzle+update_store_ownership) — never a hand-rolled puzzle.- Pubkey resolution:
--pubkey <96-hex>(a BLS12-381 G1 public key) if given; otherwiseGET https://<origin>/.well-known/dig/pubkey→{"pubkey": "<96-hex>"}. The canonical wire contract for this endpoint (path, method, response shape, failure semantics) is normative in the superprojectSYSTEM.md→ Shared contracts → "Well-known origin pubkey discovery"; this repo is a CONSUMER (discovery client) only — the endpoint's SERVING side is implemented by whichever origin is being authorized (e.g. a hub), not by dig-store. - Merge semantics:
update_store_ownership's delegated-puzzle set is a REPLACE, not an append, so the command reads the store's CURRENT delegated puzzles first and re-sends every existing delegate plus the new writer — an existing admin/writer/oracle delegate is never dropped by authorizing another writer. - Idempotent: authorizing an already-authorized pubkey is a no-op (no spend,
tx_id: null,already_authorized: truein--jsonoutput). - A writer delegate may advance the store's root (publish capsules) but can never change ownership or the delegated set itself — only the owner key can.
Scope note: like §9, this is a CLI/economic contract, not a .dig byte-format contract; it is
normative for how a dig-store reimplementation prices a capsule.
Minting a store (init) is FREE of $DIG (XCH network fee only). The $DIG payment is
attached ONLY to a commit / root-advance (a capsule), atomic with the singleton update in one
co-signed bundle (§ the enforcement in digstore_chain::dig::verify_commit_pays_dig_treasury).
The per-capsule price is dynamic and USD-pegged, NOT a fixed token amount:
dig_amount = target_usd ÷ live_dig_usd, wheretarget_usd ≈ $1 / capsule / year(realistic AWS hosting for one fixed-size capsule) and the amount is uniform per capsule (every capsule is the same fixed size, so size-varying pricing is FORBIDDEN — it would re-leak content size).- ONE canonical source. The price is computed on the DIGHub server (the pure formula +
USD-target constants; a live DIG→USD oracle) and served at
GET https://hub.dig.net/v1/pricingasmint_dig(the capsule price in DIG base units; 1 DIG = 1000 base units). Adig-storeimplementation MUST consume this SAME source so it never diverges from what DIGHub charges — it MUST NOT hard-code a fixed price or reimplement the formula/oracle. The response is additive-only ({dig_usd, computed_at, source, mint_dig, mint_usd, subdomain_dig, subdomain_usd, cert_dig, cert_usd, basis}); a reader takesmint_digand ignores unknown fields. - Resolution order (commit/deploy): an explicit amount —
--dig-amount <DIG>flag >DIGSTORE_DIG_AMOUNTenv >dig.tomldig-amount(a human DIG decimal, ≤ 3 dp) — always wins and is deterministic (no fetch). Absent any override, the CLI FETCHES the live price from the pricing endpoint and usesmint_dig. The endpoint URL is overridable viaDIGSTORE_PRICING_URL(defaulthttps://hub.dig.net/v1/pricing). - Fail LOUD (money-path discipline). If no explicit amount is set AND the pricing endpoint is
unreachable/undecodable/omits a valid
mint_dig, the command MUST error clearly (pointing the user at--dig-amount) and spend NOTHING — it MUST NOT silently fall back to a stale flat amount. (The endpoint has its own server-side fallback price, so a reachable endpoint always returns a usablemint_dig; dig-store surfaces a note whensourceis"fallback"/"… (stale)".) - The amount displayed to the user (and in
--dry-run'scost_dig) is byte-for-byte the amount built into the on-chain DIG-CAT payment (digstore_chain::cat::build_dig_store_payment).
Scope note: a CLI/economic contract (not a .dig byte-format contract) governing how the money
commands choose which XCH coins fund a spend, so a coin-fragmented wallet is never silently unable
to publish. This is dig-store's expression of the ecosystem-wide coin-management contract
(SYSTEM.md → coin-management; the shared primitive is dig-wallet-backend's engine seam engine::selection). A dig-store
reimplementation MUST replicate it and MUST NOT hand-roll its own selection heuristic.
Every XCH-funding spend built by init (mint fee), commit and deploy (root-advance XCH fee):
- Selects high-value-first — candidate XCH coins are ordered by amount DESCENDING, tie-broken by
coin id (deterministic regardless of the order the chain returned them). The largest coins are
taken greedily until the target (
feefor a root advance,fee + 1for a mint) is met. This minimizes the number of inputs, keeping the bundle's CLVM cost well under Chia's per-block ceiling (§11.3). - Caps the attempt at 50 coins — digstore uses a LOCAL
COIN_CAP = 50, distinct from dig-wallet-backend'sDEFAULT_COIN_CAP = 500, because digstore's spend bundles must stay under Chia's mempool cost ceiling. Only the largest 50 coins are eligible for a single dig-store XCH-funding spend. - Distinguishes three outcomes — never a flat failure that hides the counts:
- selectable — the largest ≤ 50 coins cover the target; the bundle is built from exactly those.
- needs consolidation — the wallet's TOTAL XCH covers the target, but the largest 50 coins do not; the spend cannot be built within the cap.
- insufficient funds — the wallet's total XCH is below the target. DISTINCT from (2): consolidation cannot create value, so "not enough money" is never reported as "too fragmented".
- On "needs consolidation", runs an auto-consolidate loop (with the user's consent): build a
CONSOLIDATION spend that merges the highest-value coins (≤ 50) into a SINGLE coin back to the
wallet, reserving a fee; broadcast it; WAIT for the merged coin to confirm on-chain; re-scan the
wallet; then re-attempt the original spend. Repeat until the spend is selectable, the user
declines, or a bounded round limit. Consent is required because consolidation spends a real XCH
fee:
--consolidate(or the global--yes) proceeds without prompting; an interactive run asks ([y/N], default No); a non-interactive run without the flag fails with a clearNEEDS_CONSOLIDATIONerror (exit 18) rather than spending unprompted.--jsonemits a{"event":"consolidated", asset, merged_coins, merged_mojos, output_coin_id, tx_id}record per round. - Never hand-rolls the selection or the merge — both are the
dig-wallet-backendprimitives (select_for_spend/select_for_consolidation); only the bundle construction (the datalayer_driver /chia-wallet-sdkbuilder) stays dig-store's.
The per-capsule $DIG (CAT) payment (§10) rides in the same commit/deploy bundle; its selection is largest-first. (Capping + consolidating the $DIG-CAT side under the same contract is a follow-up.)
Scope note: like §9–10, this is a CLI/off-chain-JSON contract (nft mint/nft bulk/collection create/collection mint), not a .dig byte-format contract; it is normative for how a
dig-store reimplementation reads/writes CHIP-0007 documents so third-party tooling (and the
chip35_dl_coin wasm) stays byte-compatible (see SYSTEM.md → CHIP-0007 metadata contract).
CHIP-0007 defines two distinct attribute shapes that MUST NOT be confused (issue #187):
- NFT item
attributes(an individual NFT's traits,Chip0007Metadata.attributes/ManifestItem.attributes) — each entry is{"trait_type": "<category>", "value": "<value>"}. The field istrait_type. - Collection
attributes(the collection-level block — icon/banner/website/twitter/etc,Collection.attributesand thecollectionblock embedded in each item's CHIP-0007 JSON,CollectionRef.attributes) — each entry is{"type": "<category>", "value": "<value>"}. The field istype, NOTtrait_type.
A dig-store implementation:
- MUST serialize collection-level attributes with the field name
type(nevertrait_type). - MUST serialize NFT-item attributes with the field name
trait_type(nevertype). - MUST, on READ, additionally accept
trait_typeas an alias for a collection attribute'stypefield (back-compat, §5.2/format-compat discipline: an already-emitted DIG collection.json using the old, non-conformanttrait_typespelling still parses). This is a READ-only accommodation — it MUST NOT change what is WRITTEN. - MUST NOT accept
typein place oftrait_typefor an NFT item's attributes — the two shapes stay distinct; item attributes are conformant CHIP-0007 as originally implemented and are not part of this alias.
Example collection.json fragment (conformant):
{
"id": "dig-punks",
"name": "DIG Punks",
"attributes": [{ "type": "icon", "value": "https://dig.net/icon.png" }],
"royalty_puzzle_hash": "…",
"royalty_basis_points": 300
}Example per-item CHIP-0007 JSON fragment (conformant — note trait_type for the item's own
attributes vs. type inside the embedded collection block):
{
"format": "CHIP-0007",
"name": "DIG Punk #1",
"collection": {
"id": "dig-punks",
"name": "DIG Punks",
"attributes": [{ "type": "icon", "value": "https://dig.net/icon.png" }]
},
"attributes": [{ "trait_type": "Background", "value": "Blue" }]
}Every --did flag (collection mint, collection show, nft mint --did) accepts EITHER form:
- a 64-hex launcher id (a leading
0xis tolerated), or - a
did:chia:1…bech32m address — the form Sage and CNI display DIDs in. Chia's DID bech32m encoding uses the literal"did:chia:"(colon included) as the bech32 human-readable part, so the FULL string (not a stripped suffix) is the bech32m payload; adig-storereimplementation decodes it the same way it would decode anxch1…/nft1…bech32m address, then checks the decoded prefix is exactly"did:chia:".
A malformed bech32m string, or one whose decoded prefix isn't "did:chia:", is rejected with a
clear argument error — never silently treated as hex.
collection mint bulk-mints every item in a manifest into a collection, attributed to a creator
DID, in ONE atomic bundle authorized by a single DID spend (§11 above covers the metadata shape;
this subsection covers the on-chain funding a reimplementation must replicate).
Each item's NFT launcher is created via the standard Chia bulk-mint idiom: a 0-value
"intermediate" coin parented off the DID's current coin, whose own spend creates a 1-mojo
singleton launcher coin. Chia's coin-value conservation is bundle-wide, not per-coin — that
0→1 mojo creation must be balanced by an equal-or-greater deficit elsewhere in the SAME spend
bundle. The DID's own spend (did.update) recreates its coin at EXACTLY its current amount, so it
cannot supply more than one item's worth of that value on its own.
Consequently:
- N = 1 item: the DID's own coin funds the single launcher directly; no separate funding coin is required (this is the original, on-chain-validated single-item path).
- N > 1 items: a separate XCH coin MUST be spent in the same bundle, contributing at least
Nmojos (1 per item) via the wallet's standard puzzle. A reimplementation:- selects an XCH coin (or several) covering at least
Nmojos from the minter's wallet, erroring with a clear, actionable "insufficient funds — needN, have<balance>" message otherwise; - spends it through the standard layer, returning any amount over
Nas CHANGE to the funding coin's own address — the excess MUST NOT be left to silently become network fee; - includes that spend in the SAME
SpendBundleas the DID spend and every item's launcher/mint spends, signed together.
- selects an XCH coin (or several) covering at least
The collection definition, per-item metadata, and royalty/attribution semantics are unaffected — only the coin-level funding differs between N=1 and N>1.
A single spend bundle for N items grows with N; once its total CLVM cost exceeds Chia's per-block
cost ceiling the full node rejects the push_tx, so an arbitrarily large collection mint MUST be
split into cost-bounded batches. A reimplementation:
- Cost-bounds each batch, computed — never a hard-coded count. The per-block CLVM cost ceiling
is
MAX_BLOCK_COST_CLVM = 11_000_000_000(Chia mainnetConsensusConstants::max_block_cost_clvm). A batch's estimated cost MUST stay at or under a conservative fraction of that ceiling (dig-store uses1/4) so that estimate error, block contention, and gateway request-size limits are all absorbed. The estimate isbase + per_item * nwhere the per-item constant is proven conservative against the real Chia consensus cost model (run_spendbundleunderMAINNET_CONSTANTS): the measured marginal per-item cost MUST NOT exceed the constant. The default batch size is the largestnwhose estimate fits the budget (≥ 1). An explicit--batch-sizeoverride is honoured but MUST be validated against the same budget; a too-large size is a terminal error naming the maximum allowed size, never a retryable one. - One batch = one self-contained bundle, built, funded (§11.2 N-launcher funding — a separate XCH coin contributing 1 mojo per item in the batch, change returned), signed, broadcast, and confirmed before the next batch. Every batch is attributed to the SAME creator DID: each batch spends the DID exactly once (advancing it one generation), and the next batch spends the DID coin the prior batch recreated. The DID's acknowledgement of every item's attribution is preserved on every batch.
- Resumable (mainnet money). Because each batch spends real XCH, a failure after batch K MUST NOT
re-mint or double-spend batches
0..=Kon re-run. Per-batch progress (item range, the DID coin spent, the recreated DID coin as the deterministic confirmation target, the tx id, and the minted launcher ids, each flagged confirmed once its landing is verified on chain) is persisted, keyed by a stable fingerprint of the manifest bytes so a resume applies ONLY to the same collection + DID + manifest + batch size. A re-run skips confirmed batches and reconciles a pushed-but-unconfirmed tail against chain — if the tail's recreated DID coin already exists, the batch landed and is marked confirmed. Correctness rests on the DID being single-use per generation: at most one mint can confirm per DID generation, so a rebuilt batch can never double-mint. - The oversized-bundle rejection is terminal, not transient. A bundle whose serialized
generator-byte cost alone (
bytes * cost_per_byte,cost_per_byte = 12_000) meets or exceedsMAX_BLOCK_COST_CLVMis definitively too large; broadcast MUST refuse it up-front with an actionable "transaction SIZE limit — split into smaller batches" error, and MUST NOT retry it or misreport it as a coinset.org connectivity/error decoding response bodyproblem (the transient-retry path is for genuine transport hiccups only).
nft mint writes the art + generated CHIP-0007 metadata into a real capsule and sets the minted
NFT's on-chain NFT1 data_uris / metadata_uris to TWO entries each, in this fixed order:
- the canonical bare root-pinned URN
urn:dig:chia:<storeId>:<rootHash>/<resourceKey>(the data resource key isart; the metadata resource key ismetadata.json) — the PRIMARY entry; - an https gateway url
<gateway>/urn:dig:chia:<storeId>:<rootHash>/<resourceKey>— the FALLBACK (<gateway>defaults tohttps://rpc.dig.net;--gateway <base>overrides the host).
NFT1 uris/meta_uris are lists that accept multiple backup urls, so both are emitted: a DIG-aware
wallet resolves the URN natively (dig-node / rpc.dig.net) while a legacy wallet (Sage) uses the https
url. The URN is root-PINNED because NFT media is immutable content — it names the exact capsule
generation the on-chain data_hash/metadata_hash are pinned to. A conforming reimplementation MUST
emit the canonical bare urn:dig:chia:… form (the single resource-identifier grammar, §URN) —
never a dig://-prefixed URN — and MUST keep the URN first. The list is additive: an old reader
simply reads whichever entry it understands.
How the dig-store CLI binary + its digs alias are built and released. The shape is copied from
the ecosystem's reference nightlies implementation (dig-updater); the ops runbook is
runbooks/release.md.
Releases are batched to a nightly cron plus manual dispatch — NOT cut on every merge to main.
Two channels ship from one orchestrator (.github/workflows/nightly-release.yml):
The orchestrator triggers ONLY on:
schedule: cron '0 0 * * *'— midnight UTC (GitHub Actions cron is always UTC; a top-of-hour cron MAY be delayed under load — acceptable, since both channels are idempotent), andworkflow_dispatchwith two inputs:channel(both|stable|nightly, defaultboth) andforce(boolean, defaultfalse).
It MUST NOT trigger on push to main. A schedule run exercises BOTH channels; a dispatch runs the
selected channel(s).
60-day auto-disable caveat. GitHub auto-disables a schedule: trigger after 60 days with no
repo activity on a public repo, with no auto-re-enable — and since this cron is the ONLY automatic
release trigger, a quiet repo can silently stop releasing with no error. Detect it with
gh api repos/DIG-Network/digs/actions/workflows/nightly-release.yml --jq .state (a value of
disabled_inactivity means it was auto-disabled) and recover with gh workflow enable nightly-release.yml (see runbooks/release.md). Any repo activity resets the 60-day counter.
Cuts a semver vX.Y.Z stable release when — and only when — the [workspace.package].version
in the root Cargo.toml has advanced beyond the newest vX.Y.Z tag (the skip-if-already-tagged
check IS the version-changed check). Cutting a release means: git-cliff regenerates
CHANGELOG.md, commits it to main as chore(release): vX.Y.Z, tags THAT commit (so the changelog
is inside the tag), and pushes commit + tag with RELEASE_TOKEN. The pushed v* tag fires
release.yml, which builds every OS/arch (both asset shapes) and publishes the GitHub Release. It
ALSO uploads the Linux x86_64 binary to the dighub S3 artifact bucket for the hub compile-worker
(tag-only — a nightly never moves the latest binary the worker reads).
force: true on a manual dispatch bypasses the skip-if-tagged guard and re-cuts the current version
(moving the tag onto a fresh changelog commit — main is never force-pushed).
Force is guarded against mutating a published release (supply-chain invariant). A force re-cut
MUST be refused — non-zero exit, clear error — when BOTH: (a) a PUBLISHED (non-draft) GitHub Release
already exists at the version's vX.Y.Z tag, AND (b) that tag currently points at a commit
DIFFERENT from the commit this run would build. Force MAY proceed when either is false: a
same-commit re-cut (a failed-build retry) or a tag with no published release (a tag repair). A
version that needs new code released MUST bump Cargo.toml, not force-move a tag.
Every night (and on demand) builds main HEAD for every OS/arch and publishes a GitHub
pre-release — so a fresh nightly always exists regardless of a version bump. It:
- Synthesizes the version at build time (nothing is committed):
X.Y.Z-nightly.YYYYMMDD.<shortsha>. As a semver prerelease it sorts BELOW the plainX.Y.Z. - Publishes under a dated tag
nightly-YYYYMMDDAND force-moves a rollingnightlytag, withprerelease: trueand neverlatest. Idempotent: a same-day re-run refreshes today's dated release + the rolling pointer. - Retention: keeps the newest 14 dated nightlies plus the rolling
nightly, pruning older dated pre-releases AND their tags together (gh release delete --cleanup-tag).v*stable tags/releases and the rollingnightlyare NEVER pruned. (The nightly channel does NOT run the S3 publish — that stays stable-only.)
The cross-OS build lives once in .github/workflows/build-binaries.yml (on: workflow_call, inputs
version + ref). Both release.yml (stable) and the nightly channel call it, so the two paths
can never diverge. It builds dig-store + the digs alias for windows-x64, linux-x64,
linux-arm64 (native ubuntu-24.04-arm runner), macos-arm64, and macos-x64, in the two asset
shapes (bare per-OS binaries + apt .tar.gz). BUILD PREREQ (§3.5 / BINDING contract D6): the
digstore-guest wasm is built for wasm32-unknown-unknown BEFORE the CLI on every leg, because
digstore-cli's build.rs embeds it.
TRANSITIONAL DUAL-PUBLISH (rename epic #703): the primary binary was renamed digstore ->
dig-store (the Cargo package name digstore-cli and all library crate names are UNCHANGED). For
one transition cycle every asset is published under BOTH the new dig-store-<ver>-<os_arch> stem
AND the legacy digstore-<ver>-<os_arch> stem (bare binaries + apt .tar.gz), and each apt tarball
ships a digstore -> dig-store compat symlink at its root, so apt.dig.net + dig-installer stay
green until they cut over. The digs alias asset name is derived independently (there is no
dig-store -> digs substring). The legacy stem + symlink drop in a later release once both
installers have cut over.
Releasing uses the RELEASE_TOKEN org PAT, not GITHUB_TOKEN. If RELEASE_TOKEN is absent, EVERY
channel NO-OPS with a clear ::warning:: — never a half-release. A concurrency: nightly-release
group (cancel-in-progress false) serializes runs so an overlapping cron + dispatch cannot race.