From d324584b2abf6d52b497eadcdee0241eefc8e0d5 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 18:50:14 +0000 Subject: [PATCH 001/109] test: prove the built DAR is consumable on its own --- .gitignore | 4 ++ consumer-smoke/consumer/daml.yaml | 25 ++++++++++ consumer-smoke/consumer/daml/Consumer.daml | 55 ++++++++++++++++++++++ consumer-smoke/multi-package.yaml | 4 ++ package.json | 1 + scripts/consumer-smoke.sh | 33 +++++++++++++ 6 files changed, 122 insertions(+) create mode 100644 consumer-smoke/consumer/daml.yaml create mode 100644 consumer-smoke/consumer/daml/Consumer.daml create mode 100644 consumer-smoke/multi-package.yaml create mode 100755 scripts/consumer-smoke.sh diff --git a/.gitignore b/.gitignore index 829b1f8..aaef965 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ # multi-package.yaml build-harness.sh creates; never committed. deps/ +# The consumer smoke test copies the built DAR here to stand in for a +# downstream repository that received the release asset. +consumer-smoke/consumer/vendor/ + node_modules docs/ diff --git a/consumer-smoke/consumer/daml.yaml b/consumer-smoke/consumer/daml.yaml new file mode 100644 index 0000000..20f8967 --- /dev/null +++ b/consumer-smoke/consumer/daml.yaml @@ -0,0 +1,25 @@ +# Stands in for a downstream repository that received only the release asset. +# The DAR is copied into vendor/ by scripts/consumer-smoke.sh rather than +# referenced in the build tree, because a consumer has no build tree of ours to +# point at. +sdk-version: 3.4.11 +name: consumer-smoke +source: daml +version: 0.0.1 +dependencies: + - daml-prim + - daml-stdlib +data-dependencies: + - vendor/canton-token-forge-0.0.1.dar +build-options: + - --target=2.1 + # The DAR bundles these six packages but damlc hides them: importing + # Splice.Api.Token.* without these lines fails with "member of the hidden + # package". The compiler suggests ':set -package', which is a GHCi command + # and not the fix. + - --package=splice-api-token-holding-v1-1.0.0 + - --package=splice-api-token-metadata-v1-1.0.0 + - --package=splice-api-token-transfer-instruction-v1-1.0.0 + - --package=splice-api-token-allocation-v1-1.0.0 + - --package=splice-api-token-allocation-instruction-v1-1.0.0 + - --package=splice-api-token-burn-mint-v1-1.0.0 diff --git a/consumer-smoke/consumer/daml/Consumer.daml b/consumer-smoke/consumer/daml/Consumer.daml new file mode 100644 index 0000000..abe4897 --- /dev/null +++ b/consumer-smoke/consumer/daml/Consumer.daml @@ -0,0 +1,55 @@ +-- Every import below resolves through canton-token-forge-0.0.1.dar alone. The +-- Splice interface packages are bundled inside that DAR but hidden by default; +-- the --package lines in daml.yaml are what make them visible. +module Consumer where + +import Splice.Api.Token.HoldingV1 qualified as HoldingV1 +import Splice.Api.Token.MetadataV1 (Metadata, emptyMetadata) +import Splice.Api.Token.TransferInstructionV1 qualified as TransferInstructionV1 +import Splice.Api.Token.AllocationV1 qualified as AllocationV1 +import Splice.Api.Token.AllocationInstructionV1 qualified as AllocationInstructionV1 +import Splice.Api.Token.BurnMintV1 qualified as BurnMintV1 +import Canton.TokenForge.Token (Token(..), mkToken) +import Canton.TokenForge.Registry (InstrumentConfig(..)) + +ownerOf : HoldingV1.HoldingView -> Party +ownerOf v = v.owner + +adminOf : InstrumentConfig -> Party +adminOf c = c.admin + +mkOne : Party -> Party -> Text -> Token +mkOne admin owner instrumentId = mkToken admin owner instrumentId 1.0 emptyMetadata + +-- Downcasting a Splice Holding to our Token needs the interface INSTANCE the +-- DAR was compiled with, not just the interface's types. The signatures above +-- would still typecheck against types alone, so this is the line that proves +-- the instance survives into the artifact. +downcast : ContractId HoldingV1.Holding -> Update (Optional (ContractId Token, Token)) +downcast = fetchFromInterface @Token + +-- The shape cc-vesting-contracts needs: one of our templates held by reference +-- from a foreign package. +template VestingLockShape + with + admin : Party + beneficiary : Party + locked : ContractId Token + meta : Metadata + where + signatory admin, beneficiary + nonconsuming choice VestingLockShape_Peek : Optional Party + controller beneficiary + do + optToken <- downcast (toInterfaceContractId @HoldingV1.Holding locked) + pure $ fmap (\(_, t) -> t.owner) optToken + +-- Naming a type from each remaining bundled package, so one dropping out of the +-- DAR is a compile error here rather than a surprise downstream. +bundledPackagesReachable : + ( TransferInstructionV1.TransferInstructionResult_Output + , AllocationV1.AllocationView + , AllocationInstructionV1.AllocationInstructionResult_Output + , BurnMintV1.BurnMintFactoryView + ) -> () +bundledPackagesReachable _ = () diff --git a/consumer-smoke/multi-package.yaml b/consumer-smoke/multi-package.yaml new file mode 100644 index 0000000..caa94a2 --- /dev/null +++ b/consumer-smoke/multi-package.yaml @@ -0,0 +1,4 @@ +# Stops dpm walking up to the repo-root multi-package.yaml, which does not list +# this package and fails resolution outright. +packages: + - ./consumer diff --git a/package.json b/package.json index 0f85278..9ef7aa6 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "setup": "bash scripts/fetch-dep.sh", "postinstall": "npm run setup", "clean": "rm -rf daml/canton-token-forge/.daml daml/canton-token-forge-test/.daml", + "smoke": "bash scripts/consumer-smoke.sh", "build": "cd daml/canton-token-forge && LANG=C.UTF-8 dpm build && cd ../canton-token-forge-test && LANG=C.UTF-8 dpm build", "build:canton-token-forge": "cd daml/canton-token-forge && LANG=C.UTF-8 dpm build", "sandbox": "bash scripts/sandbox.sh", diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh new file mode 100755 index 0000000..dfdd06f --- /dev/null +++ b/scripts/consumer-smoke.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# +# consumer-smoke.sh - build a package whose ONLY data-dependency is the built +# canton-token-forge DAR. +# +# This is the check that guards what a downstream repository actually does: if +# the artifact ever stops being consumable on its own, or the exposure flags +# stop matching what the DAR bundles, this goes red before a release is +# published. +# +# Usage: +# bash scripts/consumer-smoke.sh # needs the production DAR already built +# +set -euo pipefail +export LANG="${LANG:-C.UTF-8}" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +DAR="daml/canton-token-forge/.daml/dist/canton-token-forge-0.0.1.dar" +if [ ! -f "$DAR" ]; then + echo "missing $DAR - run 'npm run build:canton-token-forge' first" >&2 + exit 1 +fi + +# Copied, not referenced. Pointing at the build tree would still compile and +# would make this a weaker stand-in than the consumer it represents. +rm -rf consumer-smoke/consumer/vendor consumer-smoke/consumer/.daml +mkdir -p consumer-smoke/consumer/vendor +cp "$DAR" consumer-smoke/consumer/vendor/ + +( cd consumer-smoke/consumer && dpm build ) +echo "consumer smoke test passed: the DAR is consumable standalone" From 9c490e9fa30bfc20cfc910be3aaad486dd8af7dc Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 19:21:52 +0000 Subject: [PATCH 002/109] fix: list the consumer smoke package in the template-id check --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e94565f..7696739 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,6 +175,9 @@ jobs: fi checked=( RUNBOOK.md + # Listed to keep the comparison below exhaustive. Unlike the rest, + # this file's imports are compiler-checked, by `npm run smoke`. + consumer-smoke/consumer/daml/Consumer.daml registry/.env.example registry/test/e2e/helpers/sandbox.ts registry/test/helpers/fixtures.ts From 0d09af6f06b857d5661f330be971e00e87173c6b Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 19:21:52 +0000 Subject: [PATCH 003/109] fix: build the DAR and read its version in the consumer smoke test --- consumer-smoke/consumer/daml.yaml | 2 +- scripts/consumer-smoke.sh | 39 +++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/consumer-smoke/consumer/daml.yaml b/consumer-smoke/consumer/daml.yaml index 20f8967..2fa9bba 100644 --- a/consumer-smoke/consumer/daml.yaml +++ b/consumer-smoke/consumer/daml.yaml @@ -10,7 +10,7 @@ dependencies: - daml-prim - daml-stdlib data-dependencies: - - vendor/canton-token-forge-0.0.1.dar + - vendor/canton-token-forge.dar build-options: - --target=2.1 # The DAR bundles these six packages but damlc hides them: importing diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh index dfdd06f..29df14a 100755 --- a/scripts/consumer-smoke.sh +++ b/scripts/consumer-smoke.sh @@ -3,13 +3,17 @@ # consumer-smoke.sh - build a package whose ONLY data-dependency is the built # canton-token-forge DAR. # -# This is the check that guards what a downstream repository actually does: if -# the artifact ever stops being consumable on its own, or the exposure flags -# stop matching what the DAR bundles, this goes red before a release is -# published. +# This is the check that guards what a downstream repository actually does: that +# the artifact is consumable on its own, and that the exposure flags still match +# what the DAR bundles. Nothing runs it automatically, so run it before +# publishing a release. # # Usage: -# bash scripts/consumer-smoke.sh # needs the production DAR already built +# bash scripts/consumer-smoke.sh # build the DAR, then compile against it +# SKIP_BUILD=1 bash scripts/consumer-smoke.sh # compile against the already-built DAR +# +# Env overrides: +# SKIP_BUILD set to 1 to skip the dpm build step # set -euo pipefail export LANG="${LANG:-C.UTF-8}" @@ -17,17 +21,32 @@ export LANG="${LANG:-C.UTF-8}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -DAR="daml/canton-token-forge/.daml/dist/canton-token-forge-0.0.1.dar" -if [ ! -f "$DAR" ]; then - echo "missing $DAR - run 'npm run build:canton-token-forge' first" >&2 +DIST="daml/canton-token-forge/.daml/dist" + +# Building by default is what keeps the result honest: the step below reads +# whatever DAR is on disk, and a stale one passes while describing source that +# is no longer there. +if [ "${SKIP_BUILD:-0}" != "1" ]; then + npm run build:canton-token-forge +fi + +# Globbed rather than named: the version comes from daml.yaml, and hardcoding it +# here would report "DAR not found" after a build that just succeeded. An absent +# dist directory makes ls exit non-zero, which under pipefail would kill the +# script before the message below ever runs. +DAR="$(ls -1 "$DIST"/canton-token-forge-*.dar 2>/dev/null | head -n 1 || true)" +if [ -z "$DAR" ]; then + echo "no DAR in $DIST - run 'npm run build:canton-token-forge' first" >&2 exit 1 fi # Copied, not referenced. Pointing at the build tree would still compile and -# would make this a weaker stand-in than the consumer it represents. +# would make this a weaker stand-in than the consumer it represents. The +# version-free destination name is what keeps the version out of the consumer's +# daml.yaml. rm -rf consumer-smoke/consumer/vendor consumer-smoke/consumer/.daml mkdir -p consumer-smoke/consumer/vendor -cp "$DAR" consumer-smoke/consumer/vendor/ +cp "$DAR" consumer-smoke/consumer/vendor/canton-token-forge.dar ( cd consumer-smoke/consumer && dpm build ) echo "consumer smoke test passed: the DAR is consumable standalone" From 12ad4690db1a82bb7bb208ddb3377cae34007326 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 19:21:52 +0000 Subject: [PATCH 004/109] chore: clean the consumer smoke output and document npm run smoke --- ARCHITECTURE.md | 3 ++- CLAUDE.md | 12 ++++++++---- package.json | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index abaa166..6606150 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -361,7 +361,8 @@ overrides from `SEED_*`/`LEDGER_*` ([`RUNBOOK.md`](RUNBOOK.md)). | `npm run build:canton-token-forge` | Build only the production package. | | `npm test` | Build the production DAR, then run the `canton-token-forge-test` suite. | | `npm run test:coverage` | Same as `npm test` with a template-focused coverage report. | -| `npm run clean` | Remove both `.daml` build dirs. | +| `npm run smoke` | Compile a package that data-depends on nothing but the built DAR (`scripts/consumer-smoke.sh`); proves the release artifact is consumable on its own. | +| `npm run clean` | Remove both `.daml` build dirs and the consumer smoke test's output. | | `npm run sandbox` | Build the DAR and run a local Canton sandbox with the JSON Ledger API. | | `npm run seed` | Seed a running sandbox with an admin, demo users, and one `InstrumentConfig`. | | `bash scripts/build-harness.sh` | Build the Amulet test harness (unused by default; conformance only). | diff --git a/CLAUDE.md b/CLAUDE.md index 14355ab..365581d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,8 @@ minutes. For deps only, run `bash scripts/fetch-dep.sh`. | `npm run build:canton-token-forge` | Build only the production package. | | `npm test` | Build the `canton-token-forge` DAR, then run the `canton-token-forge-test` suite. | | `npm run test:coverage` | Same, with a coverage report focused on your templates. | -| `npm run clean` | Remove both `.daml` build dirs. | +| `npm run smoke` | Build the DAR, then compile a package that data-depends on nothing but it, proving the artifact is consumable on its own. | +| `npm run clean` | Remove both `.daml` build dirs and the consumer smoke test's output. | | `npm run setup` | Re-vendor deps + re-create the stable symlinks. | | `npm run sandbox` | Build the DAR and run a local Canton sandbox with the JSON Ledger API. | | `npm run seed` | Seed a running sandbox with an admin, demo users, and one `InstrumentConfig`. | @@ -254,6 +255,8 @@ Run before declaring work done: - `npm run build` - both packages compile - `npm test` - the integration suite passes - `npm run test:coverage` - when you touched or added templates +- `npm run smoke` - when you changed what the production DAR exposes: its + dependencies, its interface instances, or its `build-options` Every pull request has the strings that spell a template id verified against `daml/`, whatever it touches: the `daml` check runs that comparison ahead of @@ -261,9 +264,10 @@ its toolchain install and ignores its own scope gate. One that touches `daml/` or the root build inputs runs the first three of these automatically in that same check, after that step. One that touches `registry/` runs that package's own lint, both typechecks, and unit suite as -the `registry` check, not the root commands above. `npm run test:coverage` and -the registry's `npm run test:e2e` stay manual: the first re-runs Splice's own -suites, and the second needs a live participant. The end-to-end suite is +the `registry` check, not the root commands above. `npm run test:coverage`, +`npm run smoke` and the registry's `npm run test:e2e` stay manual: the first +re-runs Splice's own suites, the second builds a second Daml package, and the +third needs a live participant. The end-to-end suite is typechecked on that path even so, which is the point of typechecking it separately from the run. diff --git a/package.json b/package.json index 9ef7aa6..c1f32ca 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "setup": "bash scripts/fetch-dep.sh", "postinstall": "npm run setup", - "clean": "rm -rf daml/canton-token-forge/.daml daml/canton-token-forge-test/.daml", + "clean": "rm -rf daml/canton-token-forge/.daml daml/canton-token-forge-test/.daml consumer-smoke/consumer/.daml consumer-smoke/consumer/vendor", "smoke": "bash scripts/consumer-smoke.sh", "build": "cd daml/canton-token-forge && LANG=C.UTF-8 dpm build && cd ../canton-token-forge-test && LANG=C.UTF-8 dpm build", "build:canton-token-forge": "cd daml/canton-token-forge && LANG=C.UTF-8 dpm build", From 70e13491e21757a2eaadeb93440d99abd3a3796e Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 19:47:52 +0000 Subject: [PATCH 005/109] fix: pick the consumer smoke DAR by the version in daml.yaml --- scripts/consumer-smoke.sh | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh index 29df14a..57012de 100755 --- a/scripts/consumer-smoke.sh +++ b/scripts/consumer-smoke.sh @@ -30,13 +30,18 @@ if [ "${SKIP_BUILD:-0}" != "1" ]; then npm run build:canton-token-forge fi -# Globbed rather than named: the version comes from daml.yaml, and hardcoding it -# here would report "DAR not found" after a build that just succeeded. An absent -# dist directory makes ls exit non-zero, which under pipefail would kill the -# script before the message below ever runs. -DAR="$(ls -1 "$DIST"/canton-token-forge-*.dar 2>/dev/null | head -n 1 || true)" -if [ -z "$DAR" ]; then - echo "no DAR in $DIST - run 'npm run build:canton-token-forge' first" >&2 +# Read from daml.yaml rather than hardcoded here, which would report "DAR not +# found" after a build that just succeeded, and rather than globbed: dpm leaves +# the DAR of every version ever built in dist, so a glob would keep picking an +# older one after a version bump and pass against a stale artifact. +VERSION="$(awk '/^version:/ {print $2; exit}' daml/canton-token-forge/daml.yaml)" +if [ -z "$VERSION" ]; then + echo "daml/canton-token-forge/daml.yaml names no version" >&2 + exit 1 +fi +DAR="$DIST/canton-token-forge-$VERSION.dar" +if [ ! -f "$DAR" ]; then + echo "missing $DAR - run 'npm run build:canton-token-forge' first" >&2 exit 1 fi From 611d21b493059e52bd476eaf27119191717cff58 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 19:47:52 +0000 Subject: [PATCH 006/109] fix: set LANG unconditionally in the consumer smoke script --- scripts/consumer-smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh index 57012de..d46b5e6 100755 --- a/scripts/consumer-smoke.sh +++ b/scripts/consumer-smoke.sh @@ -16,7 +16,7 @@ # SKIP_BUILD set to 1 to skip the dpm build step # set -euo pipefail -export LANG="${LANG:-C.UTF-8}" +export LANG=C.UTF-8 ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" From 2f889a55fca98fda1ea8d0a118683db5b084465d Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 19:47:56 +0000 Subject: [PATCH 007/109] fix: assert the SDK pin in every tracked daml.yaml --- .github/workflows/ci.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7696739..16a2c28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,17 +348,24 @@ jobs: run: | set -euo pipefail test "$(uname -m)" = "x86_64" - # DPM_SDK_VERSION is a third copy of a pin whose other two homes are - # the daml.yaml files. Assert rather than trust: bumping those and + # DPM_SDK_VERSION is one more copy of a pin whose other homes are the + # daml.yaml files. Assert rather than trust: bumping those and # forgetting this one otherwise fails much later, or silently makes - # dpm download a second SDK. - for pkg in canton-token-forge canton-token-forge-test; do - pinned="$(awk '/^sdk-version:/ {print $2; exit}' "daml/${pkg}/daml.yaml")" + # dpm download a second SDK. Discovered rather than listed, because a + # package added later carries a pin of its own and a list written + # today cannot name it. + pins="$(git ls-files '*daml.yaml')" + if [ -z "$pins" ]; then + echo "no daml.yaml is tracked, so the SDK pin went unchecked" >&2 + exit 1 + fi + while read -r manifest; do + pinned="$(awk '/^sdk-version:/ {print $2; exit}' "$manifest")" if [ "$pinned" != "$DPM_SDK_VERSION" ]; then - echo "daml/${pkg}/daml.yaml pins SDK ${pinned}, this workflow installs ${DPM_SDK_VERSION}" >&2 + echo "${manifest} pins SDK ${pinned}, this workflow installs ${DPM_SDK_VERSION}" >&2 exit 1 fi - done + done <<<"$pins" tarball="dpm-${DPM_SDK_VERSION}-linux-amd64.tar.gz" url="https://artifactregistry.googleapis.com/download/v1/projects/da-images/locations/europe/repositories/public-generic/files/dpm-sdk:${DPM_SDK_VERSION}:${tarball}:download?alt=media" curl -SLf --retry 3 --retry-all-errors --retry-delay 5 \ From f7542c14acac05f1f23af511491e6eddb91ceedb Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 19:47:56 +0000 Subject: [PATCH 008/109] docs: correct the daml.yaml pin counts and record the consumer smoke package --- ARCHITECTURE.md | 8 +++++++- CLAUDE.md | 37 ++++++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6606150..fba1327 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,13 +68,19 @@ registry/ Read-only TypeScript HTTP service servi async-handler.ts Wraps every route so a rejected promise reaches the error middleware openapi/ The CN Token Standard OpenAPI specs the service validates against test/ Unit suites (stub ledger) + test/e2e (live participant, skip-guarded) +consumer-smoke/ Stands in for a downstream repo that received only the release asset + multi-package.yaml Stops dpm resolving the package below against the root workspace + consumer/ + daml.yaml Data-depends on the vendored DAR alone; exposes its six bundled Splice packages + daml/Consumer.daml Names a type from each bundled package and downcasts a Holding to our Token scripts/ fetch-dep.sh Vendor Splice sources, derive DAR versions, create stable-name symlinks build-harness.sh Build the Amulet test harness (unused by default; conformance only) + consumer-smoke.sh Build the DAR, then compile consumer-smoke/ against nothing else sandbox.sh Build the DAR and run a local Canton sandbox with the JSON Ledger API seed.mjs Seed a running sandbox with an admin, demo users, and one InstrumentConfig deps/ Vendored Splice sources + built DARs (gitignored; never edit or commit) -multi-package.yaml Wires the two packages into one workspace +multi-package.yaml Wires the two daml/ packages into one workspace; consumer-smoke/ has its own versions.env Single version knob: SPLICE_TAG package.json npm scripts wrapping dpm ``` diff --git a/CLAUDE.md b/CLAUDE.md index 365581d..8ecaaba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Two packages: | Category | Technology | Notes | |----------|-----------|-------| | Language | Daml | LF target **2.1** (`build-options: --target=2.1`) | -| SDK | 3.4.11 | pinned in both `daml.yaml` files | +| SDK | 3.4.11 | pinned in all three `daml.yaml` files; CI asserts them | | Build tool | `dpm` (Digital Asset Package Manager) | NOT the legacy `daml` assistant (removed as of SDK 3.5) | | Task runner | `npm` scripts | thin wrappers over `dpm`; they set `LANG=C.UTF-8` | | Dependencies | Splice interface DARs | vendored into `deps/` by `scripts/fetch-dep.sh` (gitignored) | @@ -44,8 +44,11 @@ Two packages: assistant (removed as of SDK 3.5). Commands: `dpm build`, `dpm test`, `dpm install `. - SDK pinned to **3.4.11**, LF target **2.1** (`build-options: --target=2.1`) in - both `daml.yaml` files. Do not bump casually - the deps are built at this SDK/LF - and the test package unifies `daml-script` against it. + every tracked `daml.yaml`, of which there are three: the two packages under + `daml/` and the consumer smoke package. Do not bump casually - the deps are + built at this SDK/LF and the test package unifies `daml-script` against it. + The `daml` CI check reads the pin out of each one and fails if any disagrees + with the SDK the workflow installs. - A JDK (17+) and `dpm` must be on `PATH`. Install dpm with `curl https://get.digitalasset.com/install/install.sh | sh`, then `dpm install 3.4.11`. @@ -62,10 +65,17 @@ The **only** version you pin is the Splice release tag, in **`versions.env`** tracked Daml config**. - To move to a new Splice release: edit `SPLICE_TAG`, run `npm run setup`. -Exception: the **SDK/LF** pins live in the two `daml.yaml` files (static YAML -can't read env vars). If a new tag ships a different SDK, update `sdk-version` -(and possibly `--target`) there too. `scripts/build-harness.sh` derives the SDK -it installs from the vendored harness, so at least the install matches the tag. +Two exceptions. The **SDK/LF** pins live in the three `daml.yaml` files (static +YAML can't read env vars). If a new tag ships a different SDK, update +`sdk-version` (and possibly `--target`) there too. `scripts/build-harness.sh` +derives the SDK it installs from the vendored harness, so at least the install +matches the tag. + +The second is `consumer-smoke/consumer/daml.yaml`, which names the six bundled +Splice interfaces as versioned unit-ids (`--package=splice-api-token-holding-v1-1.0.0` +and its five siblings). The compiler rejects the unversioned form, so there is no +stable name to point at, and a `SPLICE_TAG` that ships a different interface +version has to be followed by hand here. Nothing derives these. ## Build & test @@ -255,8 +265,9 @@ Run before declaring work done: - `npm run build` - both packages compile - `npm test` - the integration suite passes - `npm run test:coverage` - when you touched or added templates -- `npm run smoke` - when you changed what the production DAR exposes: its - dependencies, its interface instances, or its `build-options` +- `npm run smoke` - when you changed what the production DAR exposes: a + renamed module or template, its dependencies, its interface instances, or its + `build-options` Every pull request has the strings that spell a template id verified against `daml/`, whatever it touches: the `daml` check runs that comparison ahead of @@ -266,10 +277,10 @@ in that same check, after that step. One that touches `registry/` runs that package's own lint, both typechecks, and unit suite as the `registry` check, not the root commands above. `npm run test:coverage`, `npm run smoke` and the registry's `npm run test:e2e` stay manual: the first -re-runs Splice's own suites, the second builds a second Daml package, and the -third needs a live participant. The end-to-end suite is -typechecked on that path even so, which is the point of typechecking it -separately from the run. +re-runs Splice's own suites, the third needs a live participant, and no check +runs the second at all, so a change that strands a downstream consumer is green +until someone runs it. The end-to-end suite is typechecked on that path even so, +which is the point of typechecking it separately from the run. ## References From 68bd9b4f55475850cb2aa4aac4339ef09e94a2ed Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 19:47:56 +0000 Subject: [PATCH 009/109] fix: pick the sandbox DAR by the version in daml.yaml --- scripts/sandbox.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/sandbox.sh b/scripts/sandbox.sh index c8ae60e..0c4a34b 100755 --- a/scripts/sandbox.sh +++ b/scripts/sandbox.sh @@ -33,11 +33,18 @@ if [ "${SKIP_BUILD:-0}" != "1" ]; then npm run --prefix "$REPO_ROOT" build:canton-token-forge fi -# Globbed rather than named: the version comes from daml.yaml, and hardcoding it -# here would report "DAR not found" after a build that just succeeded. -DAR="$(ls -1 "$DIST"/canton-token-forge-*.dar 2>/dev/null | head -n 1)" -if [ -z "$DAR" ]; then - echo "no DAR in $DIST - run 'npm run build:canton-token-forge' first" >&2 +# Read from daml.yaml rather than hardcoded here, which would report "DAR not +# found" after a build that just succeeded, and rather than globbed: dpm leaves +# the DAR of every version ever built in dist, so a glob would keep uploading an +# older one after a version bump. +VERSION="$(awk '/^version:/ {print $2; exit}' "$REPO_ROOT/daml/canton-token-forge/daml.yaml")" +if [ -z "$VERSION" ]; then + echo "daml/canton-token-forge/daml.yaml names no version" >&2 + exit 1 +fi +DAR="$DIST/canton-token-forge-$VERSION.dar" +if [ ! -f "$DAR" ]; then + echo "missing $DAR - run 'npm run build:canton-token-forge' first" >&2 exit 1 fi From 61350e95b874c13d6bbbff1a855def2ee8538851 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 20:22:41 +0000 Subject: [PATCH 010/109] ci: run the SDK pin assertion ahead of the scope gate --- .github/workflows/ci.yml | 66 +++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16a2c28..b194e68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,6 +294,33 @@ jobs: done exit "$status" + # DPM_SDK_VERSION is one more copy of a pin whose other homes are the + # daml.yaml files. Assert rather than trust: bumping those and forgetting + # this one otherwise fails much later, or silently makes dpm download a + # second SDK. Discovered rather than listed, because a package added + # later carries a pin of its own and a list written today cannot name it. + # Ungated for the same reason the step above is: the sweep ranges over + # every tracked manifest, including packages outside the paths the gate + # lists, and gating it would let a pull request that bumps one of those + # pass while the merge to main reds. It needs no toolchain, only the + # checkout, so it can run here rather than beside the install it guards. + - name: Verify the SDK pins + run: | + set -euo pipefail + pins="$(git ls-files '*daml.yaml')" + if [ -z "$pins" ]; then + echo "no daml.yaml is tracked, so the SDK pin went unchecked" >&2 + exit 1 + fi + while read -r manifest; do + pinned="$(awk '/^sdk-version:/ {print $2; exit}' "$manifest")" + if [ "$pinned" != "$DPM_SDK_VERSION" ]; then + echo "${manifest} pins SDK ${pinned}, this workflow installs ${DPM_SDK_VERSION}" >&2 + exit 1 + fi + echo "ok: ${manifest} pins SDK ${pinned}" + done <<<"$pins" + - name: Scope id: scope env: @@ -307,10 +334,10 @@ jobs: exit 0 fi # A blip on this fetch reds the job having verified nothing, so it - # is retried rather than taken at its word. The template-id step is - # ungated so that it runs whatever the change touches; it sits above - # this one so that a red here finds it already run and reported, - # rather than never run at all. + # is retried rather than taken at its word. The two steps above are + # ungated so that they run whatever the change touches; they sit + # above this one so that a red here finds them already run and + # reported, rather than never run at all. for attempt in 1 2 3; do git fetch --no-tags --prune origin \ "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" && break @@ -322,10 +349,10 @@ jobs: echo "Changed files:" echo "$changed" # Build and test inputs only. The files that merely name template - # ids are not listed: the check that reads them runs ahead of this - # gate and ignores it, so listing them here would install a - # toolchain and build both packages to verify nothing the change can - # reach. + # ids or carry an SDK pin are not listed: the checks that read them + # run ahead of this gate and ignore it, so listing them here would + # install a toolchain and build both packages to verify nothing the + # change can reach. if grep -Eq '^(daml/|scripts/fetch-dep\.sh$|versions\.env$|package\.json$|package-lock\.json$|multi-package\.yaml$|\.github/workflows/ci\.yml$)' <<<"$changed"; then echo "run=true" | tee -a "$GITHUB_OUTPUT" else @@ -341,31 +368,14 @@ jobs: # The published tarball is fetched by exact version and checked against a # recorded digest instead of piping an installer into a shell. Pinning is # also a correctness requirement, not only a supply-chain one: the - # installer's "latest" resolves to 3.5.5, while both daml.yaml files pin - # 3.4.11. + # installer's "latest" resolves to 3.5.5, while every tracked daml.yaml + # pins 3.4.11. The step that holds those two in agreement runs ungated, + # above. - name: Install dpm if: steps.scope.outputs.run == 'true' run: | set -euo pipefail test "$(uname -m)" = "x86_64" - # DPM_SDK_VERSION is one more copy of a pin whose other homes are the - # daml.yaml files. Assert rather than trust: bumping those and - # forgetting this one otherwise fails much later, or silently makes - # dpm download a second SDK. Discovered rather than listed, because a - # package added later carries a pin of its own and a list written - # today cannot name it. - pins="$(git ls-files '*daml.yaml')" - if [ -z "$pins" ]; then - echo "no daml.yaml is tracked, so the SDK pin went unchecked" >&2 - exit 1 - fi - while read -r manifest; do - pinned="$(awk '/^sdk-version:/ {print $2; exit}' "$manifest")" - if [ "$pinned" != "$DPM_SDK_VERSION" ]; then - echo "${manifest} pins SDK ${pinned}, this workflow installs ${DPM_SDK_VERSION}" >&2 - exit 1 - fi - done <<<"$pins" tarball="dpm-${DPM_SDK_VERSION}-linux-amd64.tar.gz" url="https://artifactregistry.googleapis.com/download/v1/projects/da-images/locations/europe/repositories/public-generic/files/dpm-sdk:${DPM_SDK_VERSION}:${tarball}:download?alt=media" curl -SLf --retry 3 --retry-all-errors --retry-delay 5 \ From c4bc9976169abe613a3c04d717aed63e4b48028f Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 20:22:41 +0000 Subject: [PATCH 011/109] docs: correct the daml.yaml counts in README and versions.env --- README.md | 2 +- versions.env | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6fc43bb..6865579 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ declares the DAR without implementing the interface. Edit `SPLICE_TAG` in `versions.env`, run `npm run setup`. No other file changes - unless the new tag ships a different SDK, in which case also update `sdk-version` -in the two `daml.yaml` files (see CLAUDE.md). +in every tracked `daml.yaml` (see CLAUDE.md). ## License diff --git a/versions.env b/versions.env index 418c428..b4dd8a5 100644 --- a/versions.env +++ b/versions.env @@ -5,6 +5,6 @@ # reads each version out of the vendored source tree and exposes it to daml.yaml # through stable-name symlinks. Do NOT pin individual DAR versions here or in # daml.yaml. To move to a new Splice release, change ONLY this line, then run -# `npm run setup`. (The SDK/LF pins live in the two daml.yaml files; if a new tag +# `npm run setup`. (The SDK/LF pins live in every tracked daml.yaml; if a new tag # ships a different SDK, update sdk-version there too - see AGENTS.md.) SPLICE_TAG=0.6.7 From 098ff310ea22183b778ad7fb58d217440168f043 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Fri, 28 Aug 2026 21:00:28 +0000 Subject: [PATCH 012/109] build: generate the release body from the smoke test package --- consumer-smoke/consumer/daml.yaml | 3 +- scripts/release-notes.sh | 94 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100755 scripts/release-notes.sh diff --git a/consumer-smoke/consumer/daml.yaml b/consumer-smoke/consumer/daml.yaml index 2fa9bba..c75e791 100644 --- a/consumer-smoke/consumer/daml.yaml +++ b/consumer-smoke/consumer/daml.yaml @@ -16,7 +16,8 @@ build-options: # The DAR bundles these six packages but damlc hides them: importing # Splice.Api.Token.* without these lines fails with "member of the hidden # package". The compiler suggests ':set -package', which is a GHCi command - # and not the fix. + # and not the fix. These exact lines are what the release body publishes, + # extracted from this file by scripts/release-notes.sh. - --package=splice-api-token-holding-v1-1.0.0 - --package=splice-api-token-metadata-v1-1.0.0 - --package=splice-api-token-transfer-instruction-v1-1.0.0 diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..18c1e91 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# +# release-notes.sh - emit the GitHub release body for a canton-token-forge DAR. +# +# The consumer snippet is EXTRACTED from consumer-smoke/consumer/daml.yaml +# rather than written here. That package is compiled against the same DAR +# moments earlier in the release workflow, so the instructions published are +# the ones just proved to compile, and the two cannot drift apart. +# +# Usage: +# bash scripts/release-notes.sh # e.g. bash scripts/release-notes.sh v0.1.0 +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TAG="${1:?usage: release-notes.sh }" +SMOKE="consumer-smoke/consumer/daml.yaml" + +# Read from daml.yaml rather than hardcoded here, which would go stale the +# moment the package version bumps and would report the wrong DAR name in a +# release body that otherwise describes the artifact just built. +VERSION="$(awk '/^version:/ {print $2; exit}' daml/canton-token-forge/daml.yaml)" +if [ -z "$VERSION" ]; then + echo "daml/canton-token-forge/daml.yaml names no version" >&2 + exit 1 +fi +DAR="daml/canton-token-forge/.daml/dist/canton-token-forge-$VERSION.dar" +DAR_NAME="$(basename "$DAR")" + +if [ ! -f "$DAR" ]; then + echo "missing $DAR - run 'npm run build:canton-token-forge' first" >&2 + exit 1 +fi + +sha="$(sha256sum "$DAR" | cut -d' ' -f1)" +pkgid="$(unzip -l "$DAR" \ + | grep -oE "canton-token-forge-${VERSION//./\\.}-[0-9a-f]{64}" | sort -u)" +if [ -z "$pkgid" ]; then + echo "could not read the main package-id out of $DAR" >&2 + exit 1 +fi +splice_tag="$(awk -F= '/^SPLICE_TAG=/ {print $2; exit}' versions.env)" +sdk="$(awk '/^sdk-version:/ {print $2; exit}' daml/canton-token-forge/daml.yaml)" + +# data-dependencies: and build-options: are the last two blocks of the smoke +# package's daml.yaml, so "from data-dependencies to end of file" is the whole +# snippet. Its comments are dropped: they explain the file to us, not the +# artifact to a consumer. +snippet="$(awk '/^data-dependencies:/,0' "$SMOKE" | grep -v '^[[:space:]]*#')" + +cat <