diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ae798fe..7cef2bf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -80,6 +80,8 @@ scripts/ release-notes.sh Emit the release body for a tag, snippet extracted from consumer-smoke/consumer/daml.yaml 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 + check-registry-deps.mjs Fail when the root and registry manifests disagree on a dependency + registry-install-smoke.sh Pack the npm package, install it into a scratch consumer, and run the bin deps/ Vendored Splice sources + built DARs (gitignored; never edit or commit) multi-package.yaml Wires the two daml/ packages into one workspace; consumer-smoke/ has its own versions.env Single version knob: SPLICE_TAG @@ -369,6 +371,8 @@ overrides from `SEED_*`/`LEDGER_*` ([`RUNBOOK.md`](RUNBOOK.md)). | `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 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 check:deps` | Fail if the root and `registry/` manifests disagree on any dependency. | +| `npm run smoke:registry` | Pack the npm package, install it into a scratch consumer, and run the bin; proves the published service is consumable on its own. | | `bash scripts/release-notes.sh ` | Emit the release body, with the consumer snippet extracted from `consumer-smoke/consumer/daml.yaml`. Refuses if `` does not name the checked-out commit, if the working tree is dirty, or if `deps/` carries no commit stamp (`npm run setup` writes it); `ALLOW_UNTAGGED=1` previews a body before the tag exists ([`RUNBOOK.md`](RUNBOOK.md#cutting-a-release)). | | `npm run clean` | Remove both `.daml` build dirs, the consumer smoke test's output, and `registry/dist`. | | `npm run sandbox` | Build the DAR and run a local Canton sandbox with the JSON Ledger API. | diff --git a/CLAUDE.md b/CLAUDE.md index 0b2f629..ec1282a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,8 @@ installing this repository needs no `dpm`, no JDK and no clone of Splice. | `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 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 check:deps` | Fail if the root and `registry/` manifests disagree on any dependency. | +| `npm run smoke:registry` | Pack the npm package, install it into a scratch consumer, and run the bin; proves the published service is consumable on its own. | | `npm run clean` | Remove both `.daml` build dirs, the consumer smoke test's output, and `registry/dist`. | | `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. | @@ -119,7 +121,9 @@ installing this repository needs no `dpm`, no JDK and no clone of Splice. `registry/` is a separate npm package with its own dependency tree; the root `npm install` does not populate `registry/node_modules`. Run its commands from -that directory. +that directory. The root manifest is also what builds and ships this service +as an installable package, and `npm run check:deps` is what keeps the two +dependency lists in step. | Command | Does | | --- | --- | @@ -281,24 +285,54 @@ Run before declaring work done: - `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 gets three comparisons whatever it touches, because the -`daml` check runs them ahead of its toolchain install and outside its own -scope gate: the strings that spell a template id, against `daml/`; the -consumer snippet in `README.md`, against `consumer-smoke/consumer/daml.yaml`; -and every tracked `daml.yaml`, against both the SDK version the workflow -installs and LF 2.1. One that touches +- `npm run check:deps` - when you changed a dependency in the root or the + `registry/` manifest; `npm run smoke:registry` never compares the two +- `npm run smoke:registry` - when you changed the root manifest, the + registry's dependencies, or either ignore file + +Every pull request gets three comparisons in the `daml` check whatever it +touches, because that check runs them ahead of its toolchain install and +outside its own scope gate: the strings that spell a template id, against +`daml/`; the consumer snippet in `README.md`, against +`consumer-smoke/consumer/daml.yaml`; and every tracked `daml.yaml`, against +both the SDK version the workflow installs and LF 2.1. One that touches `daml/`, `consumer-smoke/`, `scripts/consumer-smoke.sh` or the root build inputs runs four of these five automatically in that same check, after those -three steps: only `npm run test:coverage` does not. 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` are both off the pull-request path: the -first re-runs Splice's own suites, the second needs a live participant. The +three steps: only `npm run test:coverage` does not. One that touches +`registry/` runs that package's own lint, both typechecks, and unit suite as +the `registry` check, none of the Daml root commands above, and the `package` +check below alongside it. `npm run test:coverage` and the registry's `npm run +test:e2e` are both off the pull-request path: the first re-runs Splice's own +suites, the second needs a live participant. The `release` workflow adds checks of its own that no pull request runs. The end-to-end suite is typechecked on that path even so, which is the point of typechecking it separately from the run. +A third check, `package`, gates on `package.json`, `package-lock.json`, +`registry/`, `scripts/check-registry-deps.mjs`, +`scripts/registry-install-smoke.sh`, `.gitignore`, `.npmrc` and +`.github/workflows/ci.yml` itself, so a pull request touching `registry/` runs +both the `registry` check and this one. It has four verification steps, not +three, and only three of them sit behind that gate: `npm run check:deps`; +`npm ci`, which runs `prepare` and so compiles `registry/src` against the root +dependency set rather than `registry/`'s own, catching a type package that +arrives there transitively and is declared nowhere; and `npm run +smoke:registry`. The fourth is ungated and runs on every trigger of the +workflow regardless of what changed, ahead of the other three exactly as the +`daml` check's own comparisons run ahead of its gate: a `git check-ignore` +table confirming that build output under `registry/` stays out of git, +`registry/dist` included, which must not be committed even though it is the +package's own payload, and that the rule ignoring it lives in the root +`.gitignore` rather than a nested one. Each half catches a mutation the other +cannot see. Narrowing the root rule to `registry/dist` passes the smoke test, +the manifest guard and the placement read alike, while build output under +`registry/src` and `registry/test` silently stops being ignored, so the table +reads what the rule covers. Moving the same pattern into +`registry/.gitignore` leaves that coverage intact but subtracts +`registry/dist` from the npm pack walk, emptying the package to 8 entries from +24; only the smoke test sees that, and only when the gate lets it run, so the +table reads where the rule lives as well. + A pushed tag whose `v` is followed by a digit (`v[0-9]*`, so `vnext` and `vendor` trigger nothing) runs the `release` workflow instead: it refuses a tag `main` does not reach, builds from a clean checkout, runs the suite, diff --git a/README.md b/README.md index 99e31e9..9568ed1 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,95 @@ in both directions, so a flag, target or path that changes on one side reds rather than ships. The release body carries the same blocks, generated straight from that file. +## Consuming the registry service + +The registry service is published from this repository as an npm package, +consumed from git at a tag rather than from the public registry: + +```json +{ + "dependencies": { + "@bootnodedev/canton-token-forge": "github:BootNodeDev/canton-token-forge#v0.2.0" + } +} +``` + +Two preconditions. This repository is private, so the install needs +credentials that can read it: a token-based HTTPS credential helper or an SSH +key for `github.com`, whichever your environment already uses for private git +dependencies. And the participant the service points at must host the +`canton-token-forge` package the configured template ids name: they are checked +at boot and resolve by package name, not by package id, so which release built +the DAR does not matter, and an id the participant cannot resolve stops the +service starting. + +`v0.2.0` is the first tag to carry the npm package, and the tag this section +is written against; until it is cut, pin the commit sha instead. Do not pin +`v0.1.0`: it predates the package, so its manifest is unscoped, declares no +`bin` and no `files`, and runs `scripts/fetch-dep.sh` as a `postinstall`, +which installs no service and clones Splice into your `node_modules`. From +`v0.2.0` on the tags are one namespace, every `v[0-9]*` tag being both a DAR +release and an npm package pin. See "Consuming a release" above for the DAR +itself. + +`npm install` builds `registry/src` through the package's `prepare` script and +links one bin, `canton-token-forge-registry`, unmodified. `pnpm install` +refuses by default: pnpm will not run a git-hosted package's build scripts +unless the consumer allowlists it, and `registry/dist` is gitignored, so +`prepare` is the only thing that produces the bin. Add the resolved git +specifier to `pnpm-workspace.yaml`, using whichever key your pnpm major reads: + +```yaml +# pnpm 11 and later +allowBuilds: + "@bootnodedev/canton-token-forge@git+https://github.com/BootNodeDev/canton-token-forge.git#": true + +# pnpm 10 +onlyBuiltDependencies: + - "@bootnodedev/canton-token-forge@git+https://github.com/BootNodeDev/canton-token-forge.git#" +``` + +The key is the full resolved git specifier, not the bare package name, a +version range, or a wildcard: pnpm resolves the tag to its commit sha and +matches on that exact string. Its own refusal error prints that specifier +unquoted, and pasted that way it does not parse: `@` is a reserved indicator +in YAML, so a plain scalar cannot begin with one, and the parser answers `bad +indentation of a mapping entry`. Quote it as the block above does. The `#` is +harmless either way, since YAML opens a comment only at a `#` that follows +whitespace, and this one sits inside the scalar. Since the sha is resolved +from the tag, this entry changes whenever the pin does. With it present, +`pnpm exec canton-token-forge-registry` runs the same as `npm`'s link. + +The service reads its whole configuration from the environment, plus a `.env` +loaded from the working directory it is started in. Required, eight: +`LEDGER_API_URL`, `LEDGER_API_TOKEN`, `ADMIN_PARTY`, and the five template ids +(`INSTRUMENT_CONFIG_TEMPLATE_ID`, `PREAPPROVAL_TEMPLATE_ID`, +`LOCKED_TOKEN_TEMPLATE_ID`, `TRANSFER_INSTRUCTION_TEMPLATE_ID`, +`ALLOCATION_TEMPLATE_ID`). Optional, four: `PORT`, `LEDGER_USER_ID`, +`SHUTDOWN_TIMEOUT_MS`, `DIRECT_TRANSFER_MARGIN_MS`. The package ships +`registry/.env.example` with the full list and what each variable is for. + +Quote all five template ids in a `.env` file. Every one of them begins with +`#`, which dotenv reads as the start of a comment, so an unquoted +`INSTRUMENT_CONFIG_TEMPLATE_ID=#canton-token-forge:...` parses to the empty +string and the boot rejects it as `missing required env var +INSTRUMENT_CONFIG_TEMPLATE_ID`, naming a variable that is in fact set. Values +passed through the environment rather than a file need no quoting beyond +whatever the shell wants. + +`npm run seed`, run from a clone of this repository against a local sandbox, +prints the whole block filled in and quoted with the real admin party and the +five template ids. `scripts/seed.mjs` is not part of the package, so that is a +step you take in a checkout, not in the consumer. `ADMIN_PARTY` and all five +template ids are checked against the participant at boot, so a value it cannot +resolve stops the service starting rather than failing later. + +What the package contains: `registry/dist`, `registry/openapi` and +`registry/.env.example`, plus `package.json`, `README.md` and `LICENSE` (24 +entries). The manifest that travels with it is this repository's own, so its +`dpm` and `daml/` scripts cannot run from an installed copy; `prepare` is the +only entry npm acts on. + ## Requirements - `dpm` (Digital Asset Package Manager) and a JDK 17+ on `PATH` diff --git a/RUNBOOK.md b/RUNBOOK.md index 57a8264..4f82145 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -311,6 +311,23 @@ reason the seed script looks the way it does. ## Cutting a release +Every `v[0-9]*` tag is a DAR release, including the tag a consumer's +`package.json` pins the npm package at (see the README's "Consuming the +registry service"). `daml/canton-token-forge/daml.yaml` stays at `0.0.1` +whatever tag is cut, so every release attaches an asset under the same name, +`canton-token-forge-0.0.1.dar`. Whether two releases' assets are the same +bytes does not follow from that version string: it follows from `daml/`, +`versions.env` and the SDK being unchanged between them. Nothing under those +paths has moved since `v0.1.0`, so a tag cut from this commit would carry the +sha256 the `v0.1.0` body records; read it off the workflow's own output rather +than assuming it, as steps 1 and 4 below do. + +The root `package.json`'s `version` is the npm package's, and nothing checks +it against the tag: `release.yml` reads only `daml/canton-token-forge/daml.yaml`. +Bring it in step with the tag you are about to cut, in a commit merged to +`main` before you tag, or consumers install a package whose manifest names a +version the pin does not. + `.github/workflows/release.yml` builds and publishes. It runs the full suite, checks the DAR is byte-reproducible, and compiles `consumer-smoke/` against the built artifact before anything is published. The release body, including the @@ -357,7 +374,10 @@ CI just proved compile. ``` Then `npm run clean`, and delete that release and its tag. -2. Tag and push. This is the decision that matters: a downstream repository pins +2. Set the root `package.json`'s `version` to the tag without its `v`, and land + that on `main`. Nothing enforces this, and the tag is what a consumer's + `package.json` resolves to. +3. Tag and push. This is the decision that matters: a downstream repository pins it permanently. ```bash @@ -365,7 +385,7 @@ CI just proved compile. git push origin v0.1.0 ``` -3. Confirm the release carries `canton-token-forge-0.0.1.dar` and that its body +4. Confirm the release carries `canton-token-forge-0.0.1.dar` and that its body shows the sha256 and package-id. The download-and-compile from step 1 is worth repeating here, against the real tag. diff --git a/SPEC.md b/SPEC.md index ea885a1..9a3ed3b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -63,8 +63,15 @@ resulting exercise itself over the JSON Ledger API, forwarding the service's ### Size and status -976 lines of production Daml, 2508 lines of Daml tests, 1724 lines of TypeScript -service, 4343 lines of TypeScript tests. MIT licensed. Pre-release: the package +976 lines of production Daml, 2508 lines of Daml tests, 1739 lines of +TypeScript service, 4349 lines of TypeScript tests, each figure a +`find -name '*.daml'` (or `'*.ts'`) `| xargs wc -l` count over +`daml/canton-token-forge/daml`, `daml/canton-token-forge-test/daml`, +`registry/src` and `registry/test` respectively. The two Daml paths name the +source directory rather than the package root on purpose: a package root that +has been built also holds a `.daml/` build directory, whose name the `*.daml` +glob matches and whose regenerated data-dependency sources dwarf the figure +above. MIT licensed. Pre-release: the Daml package version is `0.0.1`, and the build is published as release `v0.1.0` for downstream repositories to pin (the tag is deliberately decoupled from the package version), with no compatibility guarantee offered across releases. @@ -578,21 +585,34 @@ Stated plainly, because they are what an evaluation turns on. ship a new interface version that compiles green while the smoke package still names the old one, and compiling that package is what catches it. One that touches `registry/` runs that package's lint, its typechecks and - the registry unit suite as the `registry` check. The end-to-end suite - needs a live participant, so only its types are checked there and it is - never run. Off that path too is `npm run test:coverage`, which re-runs - Splice's own suites and which nothing runs automatically. A pull request - in the `daml` check's scope compiles the smoke package, generates the - release body, which is the check that compares the published snippet - against the artifact, and asserts that every tracked manifest still pins - the SDK and targets LF 2.1. The release workflow still carries the rebuild - that proves the DAR is byte-reproducible, the refusal of a tag `main` does - not reach, and `release-notes.sh`'s tag guard, which the pull-request path - waives with `ALLOW_UNTAGGED` so that a body can be generated for a ref that - is not a tag. -- **Pre-release.** Version `0.0.1`, with the build published as release `v0.1.0` - for downstream repositories to pin (the tag is deliberately decoupled from the - package version). No migration story and no compatibility guarantees. + the registry unit suite as the `registry` check. The same change also runs + a third job, `package`. Three of its four steps are gated on `registry/` + among other inputs: they compare the root and `registry/` manifests, compile + `registry/src` a second time against the root dependency set rather than + `registry/`'s own by running `npm ci` (which triggers `prepare`), then pack + the npm package and install it into a scratch consumer to run the bin. The + `registry` check's own install and build see neither of those last two. The + fourth step is ungated and runs on every trigger of the workflow, a + docs-only pull request included: it reads the `.gitignore` rules that keep + build output under `registry/` out of git, and reads which file carries + them. The end-to-end suite needs a live participant, so only its types are + checked there and it is never run. Off that path too is `npm run + test:coverage`, which re-runs Splice's own suites and which nothing runs + automatically. A pull request in the `daml` check's scope compiles the smoke + package, generates the release body, which is the check that compares the + published snippet against the artifact, and asserts that every tracked + manifest still pins the SDK and targets LF 2.1. The release workflow still + carries the rebuild that proves the DAR is byte-reproducible, the refusal + of a tag `main` does not reach, and `release-notes.sh`'s tag guard, which + the pull-request path waives with `ALLOW_UNTAGGED` so that a body can be + generated for a ref that is not a tag. +- **Pre-release.** Version `0.2.0` of the npm package, `0.0.1` of the Daml + package, both deliberately decoupled from the release tags. Those tags are + one namespace rather than two: every `v[0-9]*` tag publishes the DAR as a + release asset, and from `v0.2.0` on the same tag is what a consumer's + `package.json` pins the npm package at. `v0.1.0` is the only tag cut so far + and predates the npm package, so it serves the DAR alone. No migration story + and no compatibility guarantees. --- @@ -624,5 +644,7 @@ scripts/ release-notes.sh Emit the release body from the smoke package sandbox.sh Local Canton sandbox with the JSON Ledger API seed.mjs Seed an admin, demo users and one instrument + check-registry-deps.mjs Fail when the root and registry manifests disagree + registry-install-smoke.sh Pack, install and run the npm package versions.env The single version knob: SPLICE_TAG ``` diff --git a/package.json b/package.json index db02882..952b85d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@bootnodedev/canton-token-forge", "version": "0.2.0", - "description": "A reusable, multi-instrument CIP-0056 (CN Token Standard) compliant token for demos and sandboxes", + "description": "A reusable, multi-instrument CIP-0056 (CN Token Standard) compliant token for demos and sandboxes, plus its read-only registry HTTP service", "license": "MIT", "type": "module", "scripts": {