From a752abd944e4209b2e1dd7d02f643315fa9e7355 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 12:18:05 +0000 Subject: [PATCH 01/10] test: prove the packed registry installs and serves --- package.json | 1 + scripts/registry-install-smoke.sh | 153 ++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100755 scripts/registry-install-smoke.sh diff --git a/package.json b/package.json index 71084a8..db02882 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "setup": "bash scripts/fetch-dep.sh", "clean": "rm -rf daml/canton-token-forge/.daml daml/canton-token-forge-test/.daml consumer-smoke/consumer/.daml consumer-smoke/consumer/vendor registry/dist", "smoke": "bash scripts/consumer-smoke.sh", + "smoke:registry": "bash scripts/registry-install-smoke.sh", "check:deps": "node scripts/check-registry-deps.mjs", "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", diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh new file mode 100755 index 0000000..4320759 --- /dev/null +++ b/scripts/registry-install-smoke.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Proves the npm package a consumer installs is complete and runnable: the +# tarball carries the built service and the OpenAPI specs it reads at boot, the +# root manifest declares every runtime import the bin makes, and the linked bin +# starts a server. This is the npm counterpart of `npm run smoke`, which proves +# the same thing about the DAR. +# +# No participant is needed. The boot fails only for a fault it can attribute to +# our own configuration, so an unreachable ledger warns and continues, and +# /healthz answers without touching it. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d)" +server_pid="" + +cleanup() { + if [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then + kill -TERM "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT + +fail() { echo "smoke: $*" >&2; exit 1; } + +# A port nothing is listening on. Asking the kernel for one and closing it +# immediately races with anything else on the machine, which is why the closed +# port is only ever connected TO and the served port is asserted by polling. +free_port() { + node -e 'const net = require("node:net"); const s = net.createServer(); s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => console.log(p)) })' +} + +echo "smoke: packing ${repo_root}" +# npm pack runs `prepare`, so the tarball carries a build made from the source +# in this tree rather than whatever registry/dist happened to hold. +tarball_name="$(cd "$repo_root" && npm pack --silent --pack-destination "$work")" +tarball="${work}/${tarball_name}" +[ -f "$tarball" ] || fail "npm pack produced no tarball at ${tarball}" + +# The bin and the OpenAPI specs are the two things `files` can silently drop: +# a nested .gitignore outranks the root allowlist for a path inside it, and the +# validator reads its spec lazily, so a spec left out of the tarball is a 500 +# on the first request rather than a boot failure. Both are asserted here on +# the archive itself, before anything installs it. +listing="$(tar tzf "$tarball")" +for entry in \ + package/registry/dist/index.js \ + package/registry/openapi/token-metadata-v1.yaml \ + package/registry/openapi/transfer-instruction-v1.yaml \ + package/registry/openapi/allocation-v1.yaml \ + package/registry/openapi/allocation-instruction-v1.yaml +do + grep -qxF "$entry" <<<"$listing" || fail "the tarball carries no ${entry#package/}" +done + +# The consumer lives outside the repository so npm resolves against its own +# manifest instead of walking up into ours. +consumer="${work}/consumer" +mkdir -p "$consumer" +cat > "${consumer}/package.json" <<'JSON' +{ + "name": "registry-install-smoke-consumer", + "version": "0.0.0", + "private": true +} +JSON + +echo "smoke: installing ${tarball_name}" +( cd "$consumer" && npm install --silent --no-audit --no-fund "$tarball" ) + +bin="${consumer}/node_modules/.bin/canton-token-forge-registry" +[ -x "$bin" ] || fail "the install linked no executable bin at ${bin}" + +echo "smoke: running with no configuration" +# The logger writes to stdout, so the streams are joined rather than asserted +# on stderr, where nothing would ever appear. +set +e +# index.ts loads dotenv/config, which reads $PWD/.env: run from the consumer +# directory so this asserts on a clean environment instead of whatever .env +# happens to sit in the caller's own working directory. env -i clears every +# inherited variable so a LEDGER_API_URL exported outside this script can't +# shift the failure past the one asserted below. +no_config_output="$( cd "$consumer" && env -i PATH="$PATH" "$bin" 2>&1 )" +no_config_status=$? +set -e +[ "$no_config_status" -eq 1 ] \ + || fail "expected exit 1 with no configuration, got ${no_config_status}" +case "$no_config_output" in + *"missing required env var LEDGER_API_URL"*) ;; + *) fail "expected the missing LEDGER_API_URL message, got: ${no_config_output}" ;; +esac + +echo "smoke: running against an unreachable participant" +serve_port="$(free_port)" +dead_port="$(free_port)" +prefix='#canton-token-forge:Canton.TokenForge' +# Same $PWD/.env concern as the no-config run above; the explicit env +# assignments below are the only configuration this run gets regardless. +# exec is a special builtin, so a VAR=val ahead of it is an argument to exec +# itself rather than an environment assignment for what it execs; the +# assignments have to precede exec, not follow it. +( cd "$consumer" && \ +LEDGER_API_URL="http://127.0.0.1:${dead_port}" \ +LEDGER_API_TOKEN=smoke \ +ADMIN_PARTY='admin::1220smoke' \ +INSTRUMENT_CONFIG_TEMPLATE_ID="${prefix}.Registry:InstrumentConfig" \ +TRANSFER_INSTRUCTION_TEMPLATE_ID="${prefix}.Instruction:TokenTransferInstruction" \ +PREAPPROVAL_TEMPLATE_ID="${prefix}.Registry:TokenTransferPreapproval" \ +LOCKED_TOKEN_TEMPLATE_ID="${prefix}.Locked:LockedToken" \ +ALLOCATION_TEMPLATE_ID="${prefix}.Allocation:TokenAllocation" \ +PORT="${serve_port}" \ + exec "$bin" ) > "${work}/server.log" 2>&1 & +server_pid=$! + +health="" +for _ in $(seq 1 60); do + if ! kill -0 "$server_pid" 2>/dev/null; then + cat "${work}/server.log" >&2 + fail "the service exited before it listened" + fi + health="$(curl -sf "http://127.0.0.1:${serve_port}/healthz" || true)" + [ -n "$health" ] && break + sleep 0.5 +done +[ -n "$health" ] || { cat "${work}/server.log" >&2; fail "no 200 from /healthz on port ${serve_port}"; } +case "$health" in + *'"status":"ok"'*) ;; + *) fail "unexpected /healthz body: ${health}" ;; +esac + +# /healthz is served before any validator, so it says nothing about the specs. +# /registry/metadata/v1/info is the cheapest request that passes through one of +# them and answers from configuration alone, so it needs no ledger: it is 200 +# with the specs shipped and 500 ("spec could not be read") without them. +info_status="$(curl -s -o "${work}/info.json" -w '%{http_code}' \ + "http://127.0.0.1:${serve_port}/registry/metadata/v1/info")" +[ "$info_status" = "200" ] \ + || { cat "${work}/info.json" >&2; fail "expected 200 from /registry/metadata/v1/info, got ${info_status}"; } + +echo "smoke: terminating" +kill -TERM "$server_pid" +set +e +wait "$server_pid" +shutdown_status=$? +set -e +server_pid="" +[ "$shutdown_status" -eq 0 ] \ + || fail "expected a clean exit on SIGTERM, got ${shutdown_status}" + +echo "smoke: ok (${tarball_name} installs, refuses an empty environment, serves /healthz and the metadata API, and shuts down cleanly)" From 6ccb95080bf4eeb6c82b48de7ba784f4380cb333 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 12:38:52 +0000 Subject: [PATCH 02/10] fix: report a failed pack instead of ending the run at the pack line --- scripts/registry-install-smoke.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh index 4320759..8cde2ae 100755 --- a/scripts/registry-install-smoke.sh +++ b/scripts/registry-install-smoke.sh @@ -36,7 +36,20 @@ free_port() { echo "smoke: packing ${repo_root}" # npm pack runs `prepare`, so the tarball carries a build made from the source # in this tree rather than whatever registry/dist happened to hold. -tarball_name="$(cd "$repo_root" && npm pack --silent --pack-destination "$work")" +# A compile error in prepare is the likeliest way this whole check fails, so +# the status is held rather than left to set -e, which would end the run here +# with nothing said. --silent is deliberately not passed: it silences the +# prepare script too, which is where the compiler names the file and the line. +# npm keeps its own output on stderr, so stdout is the tarball name alone, and +# the notice listing is discarded on the path that succeeds. +set +e +tarball_name="$(cd "$repo_root" && npm pack --pack-destination "$work" 2>"${work}/pack.err")" +pack_status=$? +set -e +if [ "$pack_status" -ne 0 ]; then + cat "${work}/pack.err" >&2 + fail "npm pack failed with exit ${pack_status}" +fi tarball="${work}/${tarball_name}" [ -f "$tarball" ] || fail "npm pack produced no tarball at ${tarball}" From 3c8a5c4e1031a4a8e4581715fd0d7a013648ecdc Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 12:40:37 +0000 Subject: [PATCH 03/10] fix: give the serving run the same clean environment as the no-config run --- scripts/registry-install-smoke.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh index 8cde2ae..4ca4a48 100755 --- a/scripts/registry-install-smoke.sh +++ b/scripts/registry-install-smoke.sh @@ -110,12 +110,12 @@ echo "smoke: running against an unreachable participant" serve_port="$(free_port)" dead_port="$(free_port)" prefix='#canton-token-forge:Canton.TokenForge' -# Same $PWD/.env concern as the no-config run above; the explicit env -# assignments below are the only configuration this run gets regardless. -# exec is a special builtin, so a VAR=val ahead of it is an argument to exec -# itself rather than an environment assignment for what it execs; the -# assignments have to precede exec, not follow it. -( cd "$consumer" && \ +# Same $PWD/.env concern as the no-config run above, and env -i for the same +# reason: passing the configuration through it makes these variables the only +# ones the service sees, so an optional one exported in the caller's shell +# (SHUTDOWN_TIMEOUT_MS, NODE_OPTIONS) cannot change what this run tests. +( cd "$consumer" && exec env -i \ +PATH="$PATH" \ LEDGER_API_URL="http://127.0.0.1:${dead_port}" \ LEDGER_API_TOKEN=smoke \ ADMIN_PARTY='admin::1220smoke' \ @@ -125,7 +125,7 @@ PREAPPROVAL_TEMPLATE_ID="${prefix}.Registry:TokenTransferPreapproval" \ LOCKED_TOKEN_TEMPLATE_ID="${prefix}.Locked:LockedToken" \ ALLOCATION_TEMPLATE_ID="${prefix}.Allocation:TokenAllocation" \ PORT="${serve_port}" \ - exec "$bin" ) > "${work}/server.log" 2>&1 & + "$bin" ) > "${work}/server.log" 2>&1 & server_pid=$! health="" From 062b2cd45ebd87f57119c11c2c0158392cbc1646 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 12:41:39 +0000 Subject: [PATCH 04/10] fix: clean up on an interrupt instead of leaving a server and a temp dir --- scripts/registry-install-smoke.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh index 4ca4a48..34abb32 100755 --- a/scripts/registry-install-smoke.sh +++ b/scripts/registry-install-smoke.sh @@ -23,6 +23,11 @@ cleanup() { rm -rf "$work" } trap cleanup EXIT +# A signal has to end the run rather than return into it, because by then +# cleanup has removed the work directory the next line would read. Exiting here +# lets the EXIT trap do the one cleanup, with the conventional signal status. +trap 'exit 130' INT +trap 'exit 143' TERM fail() { echo "smoke: $*" >&2; exit 1; } From 4a77882dcfaeb3f753282f6e53a355b658d9dd6c Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 12:41:58 +0000 Subject: [PATCH 05/10] fix: name a service that died before the shutdown assertion could run --- scripts/registry-install-smoke.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh index 34abb32..8364406 100755 --- a/scripts/registry-install-smoke.sh +++ b/scripts/registry-install-smoke.sh @@ -159,7 +159,13 @@ info_status="$(curl -s -o "${work}/info.json" -w '%{http_code}' \ || { cat "${work}/info.json" >&2; fail "expected 200 from /registry/metadata/v1/info, got ${info_status}"; } echo "smoke: terminating" -kill -TERM "$server_pid" +# A service that died between serving the two requests above and this line is a +# real failure, and an unguarded kill would report it as set -e ending the run +# on bash's own "no such process" rather than as something this check saw. +if ! kill -TERM "$server_pid" 2>/dev/null; then + cat "${work}/server.log" >&2 + fail "the service was already gone when the run asked it to shut down" +fi set +e wait "$server_pid" shutdown_status=$? From a5be060e29ebbf91ba39e4e3540ae68f13b50111 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 12:42:53 +0000 Subject: [PATCH 06/10] docs: say what the check establishes, what it does not, and what it needs --- scripts/registry-install-smoke.sh | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh index 8364406..bbcdd0e 100755 --- a/scripts/registry-install-smoke.sh +++ b/scripts/registry-install-smoke.sh @@ -1,15 +1,30 @@ #!/usr/bin/env bash set -euo pipefail -# Proves the npm package a consumer installs is complete and runnable: the -# tarball carries the built service and the OpenAPI specs it reads at boot, the -# root manifest declares every runtime import the bin makes, and the linked bin -# starts a server. This is the npm counterpart of `npm run smoke`, which proves -# the same thing about the DAR. +# registry-install-smoke.sh - pack the repository, install the tarball into a +# scratch consumer, and run the bin that install links. +# +# This is the npm counterpart of `npm run smoke`, which proves the same thing +# about the DAR: it is the only check here that exercises what a consumer +# actually receives. What it guards is what a green build cannot see: a file +# `files` failed to pack, a spec that ships but does not parse, a module system +# the package cannot be loaded under, a bin pointing at nothing. +# +# It does NOT establish that the root manifest declares every runtime import. +# `express` is a peer dependency of `express-openapi-validator`, so npm installs +# it at the consumer's top level and the service runs whether or not the root +# names it; comparing the two manifests is `npm run check:deps`'s job. # # No participant is needed. The boot fails only for a fault it can attribute to # our own configuration, so an unreachable ledger warns and continues, and # /healthz answers without touching it. +# +# Usage: +# npm run smoke:registry +# +# Requires a root `npm install` first, since npm pack runs prepare and prepare +# needs tsc, and network for the consumer install. Rewrites registry/dist as a +# side effect, which `npm run clean` removes. repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" work="$(mktemp -d)" From 7b47b560acd97559d166c4003e59fb2357b8cae8 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 13:21:01 +0000 Subject: [PATCH 07/10] fix: print a captured body without gluing the next line onto it --- scripts/registry-install-smoke.sh | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh index bbcdd0e..3ece049 100755 --- a/scripts/registry-install-smoke.sh +++ b/scripts/registry-install-smoke.sh @@ -46,6 +46,16 @@ trap 'exit 143' TERM fail() { echo "smoke: $*" >&2; exit 1; } +# Print a captured body, ending it with a newline whether or not it has one, so +# the smoke: line that follows starts a line of its own. A JSON error body and a +# log a crash cut off mid-write both arrive without a trailing newline. +dump() { + if [ -s "$1" ]; then + cat "$1" >&2 + [ -z "$(tail -c 1 "$1")" ] || echo >&2 + fi +} + # A port nothing is listening on. Asking the kernel for one and closing it # immediately races with anything else on the machine, which is why the closed # port is only ever connected TO and the served port is asserted by polling. @@ -67,7 +77,7 @@ tarball_name="$(cd "$repo_root" && npm pack --pack-destination "$work" 2>"${work pack_status=$? set -e if [ "$pack_status" -ne 0 ]; then - cat "${work}/pack.err" >&2 + dump "${work}/pack.err" fail "npm pack failed with exit ${pack_status}" fi tarball="${work}/${tarball_name}" @@ -151,14 +161,14 @@ server_pid=$! health="" for _ in $(seq 1 60); do if ! kill -0 "$server_pid" 2>/dev/null; then - cat "${work}/server.log" >&2 + dump "${work}/server.log" fail "the service exited before it listened" fi health="$(curl -sf "http://127.0.0.1:${serve_port}/healthz" || true)" [ -n "$health" ] && break sleep 0.5 done -[ -n "$health" ] || { cat "${work}/server.log" >&2; fail "no 200 from /healthz on port ${serve_port}"; } +[ -n "$health" ] || { dump "${work}/server.log"; fail "no 200 from /healthz on port ${serve_port}"; } case "$health" in *'"status":"ok"'*) ;; *) fail "unexpected /healthz body: ${health}" ;; @@ -171,14 +181,14 @@ esac info_status="$(curl -s -o "${work}/info.json" -w '%{http_code}' \ "http://127.0.0.1:${serve_port}/registry/metadata/v1/info")" [ "$info_status" = "200" ] \ - || { cat "${work}/info.json" >&2; fail "expected 200 from /registry/metadata/v1/info, got ${info_status}"; } + || { dump "${work}/info.json"; fail "expected 200 from /registry/metadata/v1/info, got ${info_status}"; } echo "smoke: terminating" # A service that died between serving the two requests above and this line is a # real failure, and an unguarded kill would report it as set -e ending the run # on bash's own "no such process" rather than as something this check saw. if ! kill -TERM "$server_pid" 2>/dev/null; then - cat "${work}/server.log" >&2 + dump "${work}/server.log" fail "the service was already gone when the run asked it to shut down" fi set +e From 8b485637ae9730db4c2d26be18b25db936207a7d Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 13:21:09 +0000 Subject: [PATCH 08/10] fix: read the tarball name from the last line, since npm 10 shares stdout --- scripts/registry-install-smoke.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh index 3ece049..196b96d 100755 --- a/scripts/registry-install-smoke.sh +++ b/scripts/registry-install-smoke.sh @@ -70,16 +70,22 @@ echo "smoke: packing ${repo_root}" # the status is held rather than left to set -e, which would end the run here # with nothing said. --silent is deliberately not passed: it silences the # prepare script too, which is where the compiler names the file and the line. -# npm keeps its own output on stderr, so stdout is the tarball name alone, and -# the notice listing is discarded on the path that succeeds. +# Which stream carries that depends on the npm version. npm 9 keeps everything +# but the tarball name on stderr; npm 10 runs prepare in the foreground and +# writes its banner, and a failing compiler's output, to stdout. So both streams +# are captured, both are printed on failure, and the name is the LAST line of +# stdout rather than the whole of it. set +e -tarball_name="$(cd "$repo_root" && npm pack --pack-destination "$work" 2>"${work}/pack.err")" +( cd "$repo_root" && npm pack --pack-destination "$work" ) \ + >"${work}/pack.out" 2>"${work}/pack.err" pack_status=$? set -e if [ "$pack_status" -ne 0 ]; then + dump "${work}/pack.out" dump "${work}/pack.err" fail "npm pack failed with exit ${pack_status}" fi +tarball_name="$(tail -n 1 "${work}/pack.out")" tarball="${work}/${tarball_name}" [ -f "$tarball" ] || fail "npm pack produced no tarball at ${tarball}" From 8539831153007b8362853945b67a73fd74c27192 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 13:21:15 +0000 Subject: [PATCH 09/10] fix: report why the consumer install failed instead of silencing npm --- scripts/registry-install-smoke.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh index 196b96d..e50e8e7 100755 --- a/scripts/registry-install-smoke.sh +++ b/scripts/registry-install-smoke.sh @@ -118,7 +118,19 @@ cat > "${consumer}/package.json" <<'JSON' JSON echo "smoke: installing ${tarball_name}" -( cd "$consumer" && npm install --silent --no-audit --no-fund "$tarball" ) +# --silent is left off for the same reason as the pack above, and here it is +# npm's own error that it would suppress: a silenced install failure prints +# nothing at all, on either stream. This is the step the network prerequisite +# can fail, so the status is held and the captured output printed. +set +e +( cd "$consumer" && npm install --no-audit --no-fund "$tarball" ) \ + >"${work}/install.log" 2>&1 +install_status=$? +set -e +if [ "$install_status" -ne 0 ]; then + dump "${work}/install.log" + fail "installing the tarball failed with exit ${install_status}" +fi bin="${consumer}/node_modules/.bin/canton-token-forge-registry" [ -x "$bin" ] || fail "the install linked no executable bin at ${bin}" From b94fcb5f2d1d0ca038a205b6e57c42792aff448d Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Thu, 3 Sep 2026 13:21:23 +0000 Subject: [PATCH 10/10] fix: report a service that died before the metadata request --- scripts/registry-install-smoke.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/registry-install-smoke.sh b/scripts/registry-install-smoke.sh index e50e8e7..0457393 100755 --- a/scripts/registry-install-smoke.sh +++ b/scripts/registry-install-smoke.sh @@ -196,8 +196,11 @@ esac # /registry/metadata/v1/info is the cheapest request that passes through one of # them and answers from configuration alone, so it needs no ledger: it is 200 # with the specs shipped and 500 ("spec could not be read") without them. +# || true so a service that died between the poll above and this request is +# reported by the status check below (curl writes 000 and exits non-zero on a +# refused connection) rather than aborting the run silently through set -e. info_status="$(curl -s -o "${work}/info.json" -w '%{http_code}' \ - "http://127.0.0.1:${serve_port}/registry/metadata/v1/info")" + "http://127.0.0.1:${serve_port}/registry/metadata/v1/info" || true)" [ "$info_status" = "200" ] \ || { dump "${work}/info.json"; fail "expected 200 from /registry/metadata/v1/info, got ${info_status}"; }