diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 110faf856..1dbe39fb7 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -166,15 +166,14 @@ "docs/site/src/content/docs/tutorials/publish-and-consume-discovery-index.mdx", ) -# The Relay V2 tutorial teaches readers to hand-author its registry contract -# across eleven fences rather than run a single product script, so its gate -# follows the Evidence tutorial's fence-replay shape and reuses its shared -# helper. This tuple keeps the CI trigger honest with what actually replays -# the tutorial. +# Every input the Relay V2 tutorial gate replays: the page runner and the page +# whose frontmatter it replays. The binaries it runs are Relay V2 packages, so +# package routing already carries their build inputs. RELAY_TUTORIAL_INPUTS = ( - "docs/site/scripts/check-relay-tutorial.sh", - "docs/site/scripts/check-relay-tutorial.test.mjs", - "docs/site/scripts/evidence-tutorial-fence.sh", + "docs/site/package-lock.json", + "docs/site/package.json", + "docs/site/scripts/run-tutorial.mjs", + "docs/site/scripts/tutorial-runner/**", "docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx", ) @@ -1281,7 +1280,7 @@ def classify( or any(path in DISCOVERY_TUTORIAL_INPUTS for path in paths), "relay_v2_contracts": registry_record_cross_product or bool(affected & RELAY_V2_PACKAGES) - or any(path in RELAY_TUTORIAL_INPUTS for path in paths), + or any(matches(path, *RELAY_TUTORIAL_INPUTS) for path in paths), "relay_client_contracts": bool(affected & RELAY_CLIENT_PACKAGES), "breg_contracts": breg_contracts, "evidence_contracts": bool(affected & EVIDENCE_PACKAGES), diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 4551744a6..6567f4d6f 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -620,10 +620,45 @@ def test_every_discovery_tutorial_input_replays_the_product_gate(self) -> None: def test_every_relay_tutorial_input_replays_the_product_gate(self) -> None: for path in RELAY_TUTORIAL_INPUTS: + if path.endswith("/**"): + continue with self.subTest(path=path): self.assertTrue(Path(path).is_file()) self.assertTrue(classify(self.workspace, (path,))["relay_v2_contracts"]) + def test_relay_tutorial_routing(self) -> None: + infrastructure = ( + "docs/site/scripts/run-tutorial.mjs", + "docs/site/scripts/tutorial-runner/toolsets.mjs", + "docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx", + "docs/site/package.json", + ) + for path in infrastructure: + with self.subTest(path=path): + self.assertTrue( + classify(self.workspace, (path,))["relay_v2_contracts"] + ) + + def test_relay_tutorial_inputs_cover_every_replayed_tutorial(self) -> None: + docs = Path(__file__).resolve().parents[2] / "docs/site/src/content/docs" + slugs = [] + for section in ("start", "tutorials"): + for page in sorted((docs / section).glob("*.mdx")): + frontmatter = yaml.safe_load(page.read_text().split("---\n")[1]) + declaration = frontmatter.get("tutorial_test") or {} + if declaration.get("toolset") == "relay" and "skip" not in declaration: + slugs.append(f"{section}/{page.stem}") + self.assertIn("tutorials/publish-governed-sqlite-registry", slugs) + for slug in slugs: + with self.subTest(slug=slug): + page = f"docs/site/src/content/docs/{slug}.mdx" + self.assertTrue( + any( + fnmatch.fnmatchcase(page, pattern) + for pattern in RELAY_TUTORIAL_INPUTS + ) + ) + def test_relay_v2_paths_select_the_editor_and_reverse_dependents(self) -> None: outputs = classify( self.workspace, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e27356e49..45501423d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -603,6 +603,13 @@ jobs: persist-credentials: false submodules: false + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22.12.0 + cache: npm + cache-dependency-path: docs/site/package-lock.json + - name: Cache Cargo registry uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 with: @@ -621,8 +628,28 @@ jobs: RELAY_V2_STOCK_ISSUER: "1" run: products/relay-v2/scripts/test-http.sh - - name: Relay V2 tutorial reader gate - run: docs/site/scripts/check-relay-tutorial.sh + - name: Install docs dependencies + working-directory: docs/site + run: npm ci + + - name: Test the tutorial runner + working-directory: docs/site + run: npm run test:tutorial:runner + + - name: Check tutorial command drift + working-directory: docs/site + run: npm run check:tutorial:relay:dry-run + + - name: Build the Relay V2 toolset under test + run: cargo build --locked -p registry-relay-v2 --features tooling -p registry-relayctl --bins + + - name: Execute the Relay V2 tutorial from a reader directory + env: + RELAY_BIN: ${{ github.workspace }}/target/debug/relay + RELAYCTL_BIN: ${{ github.workspace }}/target/debug/relayctl + # The page serves on 127.0.0.1:8080, which nothing on this runner + # holds, so the replay runs on the port a reader gets. + run: node docs/site/scripts/run-tutorial.mjs --gate relay relay-client-contracts: name: Relay client contract and source neutrality diff --git a/docs/site/package.json b/docs/site/package.json index 94afdc352..0c4173025 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -45,8 +45,8 @@ "check:tutorial:dry-run": "scripts/check-tutorial.sh --dry-run", "check:tutorial:discovery": "bash scripts/check-discovery-tutorial.sh", "check:tutorial:discovery:dry-run": "bash scripts/check-discovery-tutorial.sh --dry-run", - "check:tutorial:relay": "bash scripts/check-relay-tutorial.sh", - "check:tutorial:relay:dry-run": "bash scripts/check-relay-tutorial.sh --dry-run", + "check:tutorial:relay": "node scripts/run-tutorial.mjs --gate relay", + "check:tutorial:relay:dry-run": "node scripts/run-tutorial.mjs --gate relay --dry-run", "check:tutorial:evidence": "node scripts/run-tutorial.mjs --gate evidence", "check:tutorial:evidence:dry-run": "node scripts/run-tutorial.mjs --gate evidence --dry-run", "test:tutorial:runner": "node --test \"scripts/tutorial-runner/*.test.mjs\"", diff --git a/docs/site/scripts/check-relay-tutorial.sh b/docs/site/scripts/check-relay-tutorial.sh deleted file mode 100755 index e7ed44321..000000000 --- a/docs/site/scripts/check-relay-tutorial.sh +++ /dev/null @@ -1,418 +0,0 @@ -#!/usr/bin/env bash -# -# Replay the Relay V2 tutorial's own contract and command fences, end to end. -# -# What this gate is for: proving the reader journey the page documents still -# works. `relayctl init`, a schema fingerprint that is reproducible across -# runs, a contract assembled from the page's own YAML blocks that `check` -# accepts, a production gate that refuses the starter project's unreviewed -# governance by the exact documented codes and passes once that governance is -# on file, a sealed package, and a running `relay` that answers a record, -# narrows it on request, refuses to widen it, refuses an unknown record, and -# records an audit line that names the properties it released without -# recording their values. -# -# What this gate is NOT for: policing what the page says. It pins no fence -# count and no prose. It reads the page's own fences by heading, language and -# occurrence, the same way the Evidence tutorial gate does -# (scripts/evidence-tutorial-fence.sh), so a section is free to grow or reword -# without touching this file. The one thing a section owes this gate is its -# heading: renaming one breaks the address this gate names, by name, before -# any command runs. The tutorial hand-authors one contract across eleven YAML -# blocks rather than pointing at a single product runner, so this gate follows -# that shape instead of the Discovery tutorial gate's thin wrapper. -# -# Usage: -# scripts/check-relay-tutorial.sh replay the tutorial -# scripts/check-relay-tutorial.sh --dry-run resolve the fences only -# -# Configuration: -# RELAY_BIN / RELAYCTL_BIN run these exact binaries instead of -# building from source -# RELAY_TUTORIAL_CARGO_PROFILE ci (default) or release -# RELAY_TUTORIAL_PAGE tutorial page override (tests) -# RELAY_TUTORIAL_BIND override the "127.0.0.1:8080" the page -# documents, for a host where that port is -# already bound -# CARGO_TARGET_DIR defaults to the workspace target -# directory; set it to build in isolation -# from other cargo processes sharing the -# same worktree - -set -euo pipefail - -SITE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -REPO_ROOT="$(cd "$SITE_ROOT/../.." && pwd)" -FENCE="$SITE_ROOT/scripts/evidence-tutorial-fence.sh" -TUTORIAL="${RELAY_TUTORIAL_PAGE:-$SITE_ROOT/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx}" -BUILD_PROFILE="${RELAY_TUTORIAL_CARGO_PROFILE:-ci}" -TARGET_DIR="${CARGO_TARGET_DIR:-$REPO_ROOT/target}" -BIND="${RELAY_TUTORIAL_BIND:-127.0.0.1:8080}" - -DRY_RUN=0 -case "${1:-}" in -"") ;; ---dry-run) DRY_RUN=1 ;; -*) - printf 'unknown argument: %s (expected --dry-run)\n' "$1" >&2 - exit 2 - ;; -esac - -for path in "$TUTORIAL" "$FENCE"; do - if [[ ! -f "$path" ]]; then - printf 'required Relay tutorial input not found: %s\n' "$path" >&2 - exit 1 - fi -done - -WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/relay-tutorial.XXXXXX")" -FENCES="$WORK_ROOT/fences" -mkdir -p "$FENCES" - -cleanup() { - local exit_code=$? - set +e - if [[ -n "${RELAY_PID:-}" ]]; then - # serve.sh backgrounds a shell that in turn execs relay as its last - # command; signalling only the shell's PID can leave relay itself - # running, so this signals the whole background process group. - kill -TERM -"$RELAY_PID" >/dev/null 2>&1 || true - wait "$RELAY_PID" >/dev/null 2>&1 || true - fi - chmod -R u+w "$WORK_ROOT" 2>/dev/null - rm -rf "$WORK_ROOT" - if ((exit_code == 0)); then - printf 'Relay tutorial gate: PASS\n' - else - printf 'Relay tutorial gate: FAIL (exit %d)\n' "$exit_code" >&2 - fi -} -trap cleanup EXIT -trap 'exit 130' HUP INT TERM - -# Read a fence by heading, language and occurrence into $FENCES/. A -# heading a step names that the page no longer carries fails here, by name, -# whether or not this is a dry run. -wf() { - bash "$FENCE" write-fence "$TUTORIAL" "$1" "$2" "$3" "$FENCES/$4" -} - -# --------------------------------------------------------------------------- -# Resolve every fence this gate replays, in document order. This runs in -# --dry-run too, because it needs no toolchain: it is what turns a renamed -# heading into a named failure instead of a silent skip. -# --------------------------------------------------------------------------- - -wf "Start a project" sh 1 init.sh -wf "Build the register database" sql 1 registry.sql -wf "Build the register database" sh 1 build-db.sh -wf "Record the schema you reviewed" sh 1 inspect.sh -wf "Record the schema you reviewed" text 1 inspect-expected.txt -for i in 1 2 3 4 5 6 7 8 9 10 11; do - wf "Write the contract" yaml "$i" "contract-$i.yaml" -done -wf "Check the contract" sh 1 check.sh -wf "Generate the artifacts" sh 1 generate.sh -wf "See what production refuses" sh 1 check-production-refused.sh -wf "Record the review" yaml 1 classification-review.yaml -wf "Record the review" markdown 1 classification-review-rationale.md -wf "Record the review" yaml 2 legal-basis.yaml -wf "Record the review" yaml 3 record-lifecycle.yaml -wf "Record the review" sh 1 check-production-passed.sh -wf "Seal the package" sh 1 package.sh -wf "Seal the package" text 1 package-expected.txt -wf "Serve it" yaml 1 runtime-expected.yaml -wf "Serve it" sh 1 serve.sh -wf "Ask the register a question" sh 1 ready.sh -wf "Ask the register a question" sh 2 read-record.sh -wf "Ask for less, then try to ask for more" sh 1 narrow.sh -wf "Ask for less, then try to ask for more" sh 2 widen.sh -wf "Ask for less, then try to ask for more" sh 3 unknown-record.sh -wf "Read the audit entry" sh 1 audit.sh -# Named to prove the heading still exists; the reader's own cleanup of their -# directory is out of scope, since this gate manages its own work directory. -wf "Clean up" sh 1 cleanup-reader.sh - -fence_count="$(find "$FENCES" -type f | wc -l | tr -d ' ')" -printf 'Relay tutorial: resolved %d fences\n' "$fence_count" - -if ((DRY_RUN)); then - printf 'Relay tutorial reader gate: dry run only\n' - exit 0 -fi - -# --------------------------------------------------------------------------- -# Toolset under test -# --------------------------------------------------------------------------- - -resolve_profile_dir() { - case "$BUILD_PROFILE" in - ci | release) printf '%s' "$BUILD_PROFILE" ;; - *) - printf 'unsupported tutorial Cargo profile: %s (expected ci or release)\n' \ - "$BUILD_PROFILE" >&2 - exit 1 - ;; - esac -} - -if [[ -z "${RELAY_BIN:-}" || -z "${RELAYCTL_BIN:-}" ]]; then - profile_dir="$(resolve_profile_dir)" - (cd "$REPO_ROOT" && CARGO_TARGET_DIR="$TARGET_DIR" \ - CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ - cargo build --locked --profile "$BUILD_PROFILE" \ - -p registry-relay-v2 --features tooling -p registry-relayctl) - RELAY_BIN="$TARGET_DIR/$profile_dir/relay" - RELAYCTL_BIN="$TARGET_DIR/$profile_dir/relayctl" -fi -for bin in "$RELAY_BIN" "$RELAYCTL_BIN"; do - if [[ "$bin" != /* ]]; then - printf 'toolset binary path must be absolute: %s\n' "$bin" >&2 - exit 1 - fi - if [[ ! -x "$bin" ]]; then - printf 'toolset binary not executable: %s\n' "$bin" >&2 - exit 1 - fi -done - -# The tutorial calls the binaries by name. -SHIM_DIR="$WORK_ROOT/bin" -mkdir -p "$SHIM_DIR" -ln -s "$RELAY_BIN" "$SHIM_DIR/relay" -ln -s "$RELAYCTL_BIN" "$SHIM_DIR/relayctl" -export PATH="$SHIM_DIR:$PATH" - -# --------------------------------------------------------------------------- -# Replay -# --------------------------------------------------------------------------- - -READER_DIR="$WORK_ROOT/reader" -mkdir -p "$READER_DIR" -cd "$READER_DIR" - -printf '==> relayctl init\n' -# shellcheck disable=SC1091 # generated by write-fence at run time -source "$FENCES/init.sh" - -printf '==> build the register database\n' -cat "$FENCES/registry.sql" >registry.sql -bash "$FENCES/build-db.sh" - -printf '==> relayctl inspect\n' -inspect_output="$(bash "$FENCES/inspect.sh")" -printf '%s\n' "$inspect_output" -fingerprint="$(awk '/fingerprint/ { print $2 }' <<<"$inspect_output")" -if [[ -z "$fingerprint" ]]; then - printf 'tutorial drift: relayctl inspect printed no fingerprint\n' >&2 - exit 1 -fi -# The page claims this fingerprint is reproducible on any machine and every -# run, because it covers only the stored schema statements. That claim is a -# behaviour a successful `inspect` exit does not already prove: a changed -# fingerprint algorithm would still exit zero while printing a different -# value, silently. -expected_fingerprint="$(awk '/fingerprint/ { print $2 }' "$FENCES/inspect-expected.txt")" -if [[ "$fingerprint" != "$expected_fingerprint" ]]; then - printf 'tutorial behaviour drift: relayctl inspect printed %s, the page documents %s\n' \ - "$fingerprint" "$expected_fingerprint" >&2 - exit 1 -fi - -printf '==> assemble registry.yaml from the documented contract blocks\n' -: >registry.yaml -for i in 1 2 3 4 5 6 7 8 9 10 11; do - cat "$FENCES/contract-$i.yaml" >>registry.yaml - printf '\n' >>registry.yaml -done -contract="$(cat registry.yaml)" -contract="${contract//sha256:/$fingerprint}" -printf '%s' "$contract" >registry.yaml - -printf '==> relayctl check\n' -check_output="$(bash "$FENCES/check.sh")" -printf '%s\n' "$check_output" -if [[ "$check_output" != *"Authoring check passed."* ]]; then - printf 'tutorial behaviour drift: relayctl check did not pass the assembled contract\n' >&2 - exit 1 -fi - -printf '==> relayctl generate\n' -bash "$FENCES/generate.sh" -starter="generated/governance/classification-review-starter.yaml" -if [[ ! -f "$starter" ]]; then - printf 'tutorial behaviour drift: relayctl generate did not write %s\n' "$starter" >&2 - exit 1 -fi -digest="$(awk -F': ' '/classificationInventoryDigest/ { print $2 }' "$starter")" -if [[ -z "$digest" ]]; then - printf 'tutorial behaviour drift: %s carries no classificationInventoryDigest\n' "$starter" >&2 - exit 1 -fi - -printf '==> relayctl check --production (starter project, expected refusal)\n' -set +e -production_refused_output="$(bash "$FENCES/check-production-refused.sh" 2>&1)" -refusal_status=$? -set -e -printf '%s\n' "$production_refused_output" -if ((refusal_status == 0)); then - printf 'tutorial behaviour drift: production check accepted the unreviewed starter project\n' >&2 - exit 1 -fi -for code in \ - codelist.unreviewed \ - classification.review_inventory_stale \ - classification.review_registry_stale \ - classification.review_date_invalid \ - classification.review_unreviewed; do - if [[ "$production_refused_output" != *"$code"* ]]; then - printf 'tutorial behaviour drift: production refusal is missing %s\n' "$code" >&2 - exit 1 - fi -done - -printf '==> record the review\n' -review="$(cat "$FENCES/classification-review.yaml")" -review="${review//sha256:/$digest}" -printf '%s' "$review" >governance/classification-review.yaml -cat "$FENCES/classification-review-rationale.md" >governance/classification-review-rationale.md -cat "$FENCES/legal-basis.yaml" >governance/legal-basis.yaml -cat "$FENCES/record-lifecycle.yaml" >codelists/record-lifecycle.yaml - -printf '==> relayctl check --production (reviewed project, expected pass)\n' -production_passed_output="$(bash "$FENCES/check-production-passed.sh")" -printf '%s\n' "$production_passed_output" -if [[ "$production_passed_output" != *"Production check passed."* ]]; then - printf 'tutorial behaviour drift: production check did not pass the reviewed project\n' >&2 - exit 1 -fi - -printf '==> relayctl package\n' -package_output="$(bash "$FENCES/package.sh")" -printf '%s\n' "$package_output" -if [[ "$package_output" != *"Sealed a deployment package."* ]]; then - printf 'tutorial behaviour drift: relayctl package did not seal a package\n' >&2 - exit 1 -fi -expected_source_fingerprint="$(awk '/registry sha256:/ { print $2 }' "$FENCES/package-expected.txt")" -if [[ "$package_output" != *"$expected_source_fingerprint"* ]]; then - printf 'tutorial behaviour drift: the sealed package does not record the documented source fingerprint %s\n' \ - "$expected_source_fingerprint" >&2 - exit 1 -fi - -printf '==> serve it\n' -runtime_yaml="$(cat runtime.yaml)" -expected_runtime_yaml="$(cat "$FENCES/runtime-expected.yaml")" -if [[ "$BIND" != "127.0.0.1:8080" ]]; then - expected_runtime_yaml="${expected_runtime_yaml//127.0.0.1:8080/$BIND}" - runtime_yaml="${runtime_yaml//127.0.0.1:8080/$BIND}" - printf '%s' "$runtime_yaml" >runtime.yaml - # The curl fences below name the same bind address literally, the way the - # reader's own terminal would. - for client_fence in ready.sh read-record.sh narrow.sh widen.sh unknown-record.sh; do - client_content="$(cat "$FENCES/$client_fence")" - client_content="${client_content//127.0.0.1:8080/$BIND}" - printf '%s' "$client_content" >"$FENCES/$client_fence" - done -fi -# `relayctl init` writes runtime.yaml before the reader ever reads it, and the -# page says it needs no edit. If the starter template drifts from what the -# page shows the reader, this catches it. -if [[ "$runtime_yaml" != "$expected_runtime_yaml" ]]; then - printf 'tutorial behaviour drift: runtime.yaml does not match the documented file\n' >&2 - exit 1 -fi - -# Job control is off in a non-interactive script, so a background job stays -# in the script's own process group unless monitor mode is enabled here. -# Without it, the negative-PID kill below targets a process group that was -# never created and fails outright instead of reaching relay. -set -m -bash "$FENCES/serve.sh" >"$WORK_ROOT/relay.log" 2>&1 & -RELAY_PID=$! -set +m - -ready=0 -for _ in $(seq 1 50); do - if ready_output="$(bash "$FENCES/ready.sh" 2>/dev/null)" && [[ "$ready_output" == '{"status":"ready"}' ]]; then - ready=1 - break - fi - sleep 0.1 -done -if ((!ready)); then - printf 'relay did not become ready; log follows\n' >&2 - cat "$WORK_ROOT/relay.log" >&2 - exit 1 -fi - -printf '==> ask the register a question\n' -record_output="$(bash "$FENCES/read-record.sh")" -printf '%s\n' "$record_output" -for expected in '"legalName":"Aurora Freight Cooperative"' '"legalForm":"COOPERATIVE"'; do - if [[ "$record_output" != *"$expected"* ]]; then - printf 'tutorial behaviour drift: the record answer is missing %s\n' "$expected" >&2 - exit 1 - fi -done -if [[ "$record_output" == *"registeredAddress"* ]]; then - printf 'tutorial behaviour drift: the disclosure boundary leaked registeredAddress\n' >&2 - exit 1 -fi -if jq -e '.data | has("registryIdentifier")' <<<"$record_output" >/dev/null; then - printf 'tutorial behaviour drift: the record answer still carries data.registryIdentifier\n' >&2 - exit 1 -fi -if ! jq -e '.meta | has("registryIdentifier")' <<<"$record_output" >/dev/null; then - printf 'tutorial behaviour drift: the record answer is missing meta.registryIdentifier\n' >&2 - exit 1 -fi - -printf '==> ask for less, then try to ask for more\n' -narrow_output="$(bash "$FENCES/narrow.sh")" -if [[ "$narrow_output" != *'"legalName"'* ]] || [[ "$narrow_output" == *'"legalForm"'* ]]; then - printf 'tutorial behaviour drift: narrowing to legalName did not select it alone: %s\n' \ - "$narrow_output" >&2 - exit 1 -fi - -widen_output="$(bash "$FENCES/widen.sh")" -if [[ "$widen_output" != *'"code":"request.fields_invalid"'* ]] || [[ "$widen_output" != *'"status":400'* ]]; then - printf 'tutorial behaviour drift: widening to registeredAddress was not refused as documented: %s\n' \ - "$widen_output" >&2 - exit 1 -fi - -unknown_output="$(bash "$FENCES/unknown-record.sh")" -if [[ "$unknown_output" != *'"code":"consultation.unresolved"'* ]] || [[ "$unknown_output" != *'"status":404'* ]]; then - printf 'tutorial behaviour drift: the unknown record was not refused as documented: %s\n' \ - "$unknown_output" >&2 - exit 1 -fi - -printf '==> read the audit entry\n' -kill -TERM -"$RELAY_PID" -wait "$RELAY_PID" 2>/dev/null || true -unset RELAY_PID -audit_line="$(bash "$FENCES/audit.sh")" -printf '%s\n' "$audit_line" -for expected in \ - '"prev_hash":null' \ - '"phase":"attempt"' \ - '"selectedProperties":["legalName","legalForm"]'; do - if [[ "$audit_line" != *"$expected"* ]]; then - printf 'tutorial behaviour drift: the audit line is missing %s\n' "$expected" >&2 - exit 1 - fi -done -# The whole point of the audit chain in this tutorial: it records what was -# released, never the values themselves. -if [[ "$audit_line" == *"Aurora Freight Cooperative"* ]]; then - printf 'tutorial behaviour drift: the audit line recorded a released field value\n' >&2 - exit 1 -fi - -printf 'Checked 1 tutorial.\n' diff --git a/docs/site/scripts/check-relay-tutorial.test.mjs b/docs/site/scripts/check-relay-tutorial.test.mjs deleted file mode 100644 index 86fdf7d54..000000000 --- a/docs/site/scripts/check-relay-tutorial.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); -const siteRoot = new URL('..', import.meta.url); -const gatePath = fileURLToPath(new URL('check-relay-tutorial.sh', import.meta.url)); -const pagePath = fileURLToPath( - new URL( - '../src/content/docs/tutorials/publish-governed-sqlite-registry.mdx', - import.meta.url, - ), -); - -async function dryRun(env = {}) { - return execFileAsync('bash', ['scripts/check-relay-tutorial.sh', '--dry-run'], { - cwd: siteRoot, - encoding: 'utf8', - env: { ...process.env, ...env }, - }); -} - -test('Relay tutorial dry-run resolves the reader journey it found', async () => { - const { stdout } = await dryRun(); - - assert.match(stdout, /Relay tutorial: resolved \d+ fences/u); - assert.match(stdout, /Relay tutorial reader gate: dry run only/u); -}); - -test('a section renamed out from under a fence address fails by name', async () => { - const page = await readFile(pagePath, 'utf8'); - const heading = '## Write the contract'; - assert.ok(page.includes(heading), 'fixture assumption: the page still carries this heading'); - - const dir = await mkdtemp(join(tmpdir(), 'relay-tutorial-')); - const edited = join(dir, 'page.mdx'); - await writeFile(edited, page.replace(heading, '## Author the contract')); - - await assert.rejects( - () => dryRun({ RELAY_TUTORIAL_PAGE: edited }), - (error) => { - assert.match(error.stderr, /missing yaml fence 1 under "Write the contract"/u); - return true; - }, - ); -}); - -test("the gate replays the page's own fences rather than hand-duplicated commands", async () => { - const source = await readFile(gatePath, 'utf8'); - - // Every relayctl/relay invocation this gate runs comes from write-fence - // against the live page. A hand-typed command here would keep passing - // after the page changed underneath it, which is the exact failure mode - // issue #788 reported. - assert.doesNotMatch(source, /^[ \t]*relayctl [a-z]/mu); - assert.doesNotMatch(source, /^[ \t]*relay serve/mu); - assert.match(source, /wf "Write the contract" yaml/u); - assert.match(source, /wf "Serve it" sh 1 serve\.sh/u); -}); diff --git a/docs/site/scripts/evidence-tutorial-fence.sh b/docs/site/scripts/evidence-tutorial-fence.sh deleted file mode 100755 index 952f9ee24..000000000 --- a/docs/site/scripts/evidence-tutorial-fence.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env bash -# -# Read a documented fence out of a tutorial, and apply a documented -# before/after pair to a file the reader edits. -# -# The Evidence tutorial gate replays each reader journey inside a clean Debian -# userland that holds a shell, coreutils and the toolset under test, and -# nothing else. These two operations are the gate's own scaffolding, standing -# in for a reader who edits by hand, so they are written against that same -# floor: an interpreter the container does not carry fails mid-journey, where -# the transcript makes it look like a tutorial defect. -# -# Fence semantics match the site's authoring helper: a level-2 heading opens a -# section, a fence is counted per heading and language, the opening fence's -# indentation is stripped from its body, and blank lines at the body's edges -# are presentation rather than content. -# -# Usage: -# evidence-tutorial-fence.sh write-fence \ -# -# evidence-tutorial-fence.sh replace-block - -set -euo pipefail - -usage() { - printf 'usage: %s write-fence \n' \ - "${BASH_SOURCE[0]}" >&2 - printf ' %s replace-block \n' \ - "${BASH_SOURCE[0]}" >&2 - exit 2 -} - -write_fence() { - (($# == 5)) || usage - local tutorial="$1" heading="$2" language="$3" occurrence="$4" out="$5" - - if [[ ! -f "$tutorial" ]]; then - printf 'tutorial not found: %s\n' "$tutorial" >&2 - exit 1 - fi - if [[ ! "$occurrence" =~ ^[0-9]+$ ]] || ((occurrence < 1)); then - printf 'fence occurrence must be a positive integer: %s\n' "$occurrence" >&2 - exit 2 - fi - - awk -v want_heading="$heading" -v want_language="$language" \ - -v want_occurrence="$occurrence" ' - found { next } - in_fence == 0 { - if ($0 ~ /^##[ \t]+/) { - heading = $0 - sub(/^##[ \t]+/, "", heading) - sub(/[ \t]+$/, "", heading) - have_heading = 1 - next - } - if (have_heading && $0 ~ /^[ \t]*```[A-Za-z0-9_-]+[ \t]*$/) { - indent = $0 - sub(/```[A-Za-z0-9_-]+[ \t]*$/, "", indent) - language = $0 - sub(/^[ \t]*```/, "", language) - sub(/[ \t]*$/, "", language) - in_fence = 1 - n = 0 - } - next - } - { - closer = $0 - gsub(/^[ \t]+/, "", closer) - gsub(/[ \t]+$/, "", closer) - if (closer == "```") { - key = heading SUBSEP language - seen[key] += 1 - if (heading == want_heading && language == want_language && - seen[key] == want_occurrence + 0) { - first = 1 - last = n - while (first <= last && buffer[first] == "") first++ - while (last >= first && buffer[last] == "") last-- - for (i = first; i <= last; i++) print buffer[i] - found = 1 - } - in_fence = 0 - next - } - content = $0 - if (indent != "" && index(content, indent) == 1) { - content = substr(content, length(indent) + 1) - } - buffer[++n] = content - } - END { - if (!found) { - printf "missing %s fence %s under \"%s\"\n", \ - want_language, want_occurrence, want_heading > "/dev/stderr" - exit 1 - } - } - ' "$tutorial" >"$out" -} - -replace_block() { - (($# == 3)) || usage - local target="$1" before="$2" after="$3" - - local path - for path in "$target" "$before" "$after"; do - if [[ ! -f "$path" ]]; then - printf 'file not found: %s\n' "$path" >&2 - exit 1 - fi - done - - local rewritten="$target.rewritten" - awk -v beforefile="$before" -v afterfile="$after" -v targetfile="$target" ' - # Every file is read whole, because the edit is a literal block - # substitution: nothing here interprets the target as markup. - function slurp(path, text, count, line) { - text = "" - count = 0 - while ((getline line < path) > 0) { - text = (count++ ? text "\n" line : line) - } - close(path) - return text - } - BEGIN { - before = slurp(beforefile) - after = slurp(afterfile) - target = slurp(targetfile) - - if (before == "") { - print "the documented before block is empty" > "/dev/stderr" - exit 1 - } - if (before == after) { - print "fence-pair replacement must change the target" > "/dev/stderr" - exit 1 - } - - count = 0 - offset = 0 - first = 0 - while ((at = index(substr(target, offset + 1), before)) > 0) { - count += 1 - if (count == 1) first = offset + at - offset = offset + at + length(before) - 1 - } - if (count != 1) { - printf "expected one exact block in the edit target, found %d\n", \ - count > "/dev/stderr" - exit 1 - } - - printf "%s\n", substr(target, 1, first - 1) after \ - substr(target, first + length(before)) - } - ' >"$rewritten" - mv "$rewritten" "$target" -} - -(($# > 0)) || usage -command="$1" -shift -case "$command" in -write-fence) write_fence "$@" ;; -replace-block) replace_block "$@" ;; -*) - printf 'unknown command: %s\n' "$command" >&2 - usage - ;; -esac diff --git a/docs/site/scripts/run-tutorial.mjs b/docs/site/scripts/run-tutorial.mjs index 81815c64b..a029b11db 100644 --- a/docs/site/scripts/run-tutorial.mjs +++ b/docs/site/scripts/run-tutorial.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node // Replay a tutorial page the way a reader follows it. // -// node scripts/run-tutorial.mjs [--dry-run] [--toolset breg|casework|evidence|none] ... -// node scripts/run-tutorial.mjs [--dry-run] --gate breg|casework|evidence +// node scripts/run-tutorial.mjs [--dry-run] [--toolset breg|casework|evidence|relay|none] ... +// node scripts/run-tutorial.mjs [--dry-run] --gate breg|casework|evidence|relay // // The page is the specification (see tutorial-runner/page.mjs): its sh fences // run in document order in one bash shell, from an empty reader directory @@ -21,7 +21,8 @@ // tutorial_test.checkout, the reader directory starts as a copy of this // checkout instead (tutorial-runner/checkout.mjs). // -// A test-file block writes its file from the shell's current directory. A +// A test-file block writes its file from the shell's current directory, and a +// test-append block adds to the end of one there. A // test-background fence runs beside the journey until its ready URL answers, // and stays running until the next background fence starts or its page ends // (tutorial-runner/background.mjs). test-cwd moves the shell to a directory @@ -56,7 +57,7 @@ import { readJourney } from './tutorial-runner/page.mjs'; import { TOOLSETS, ToolsetError } from './tutorial-runner/toolsets.mjs'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); -const USAGE = 'usage: run-tutorial.mjs [--dry-run] [--toolset breg|casework|evidence|none] ...\n run-tutorial.mjs [--dry-run] --gate breg|casework|evidence'; +const USAGE = 'usage: run-tutorial.mjs [--dry-run] [--toolset breg|casework|evidence|relay|none] ...\n run-tutorial.mjs [--dry-run] --gate breg|casework|evidence|relay'; const DOCS_ROOT = process.env.TUTORIAL_DOCS_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), '../src/content/docs'); const APPLY_EDIT = join(dirname(fileURLToPath(import.meta.url)), 'tutorial-runner/apply-edit.mjs'); const BACKGROUND = join(dirname(fileURLToPath(import.meta.url)), 'tutorial-runner/background.mjs'); @@ -103,7 +104,7 @@ function printPlan(steps, checkout) { if (step.kind === 'run') console.log(`run ${where(step)}: ${step.code.split('\n')[0]}${note}`); else if (step.kind === 'skip') console.log(`skip ${where(step)}: ${step.reason}`); else if (step.kind === 'edit') console.log(`edit ${where(step)}: ${step.path}`); - else if (step.kind === 'file') console.log(`file ${where(step)}: ${step.path}`); + else if (step.kind === 'file') console.log(`${step.append ? 'append' : 'file '} ${where(step)}: ${step.path}`); else if (step.kind === 'excerpt' && step.path) console.log(`excerpt ${at(step)}: ${step.path}`); else if (step.kind === 'excerpt') console.log(`excerpt ${at(step)}: checks line ${steps[step.runIndex].line}`); else console.log(`expect ${at(step)}: checks line ${steps[step.runIndex].line}`); @@ -160,8 +161,16 @@ async function journeyScript(pages, outDir, readerDir) { const staged = join(outDir, `${String(index).padStart(3, '0')}.file`); await writeFile(staged, step.text); lines.push(`printf '\\n%s\\n' ${quote(`==> ${where(step)}`)}`); - lines.push(`cp -- ${quote(staged)} ${quote(step.path)} >${out} 2>&1 ${out}; false; }`); + lines.push(`cat -- ${quote(staged)} >>${quote(step.path)} 2>${out} ${out} 2>&1 ${where(step)} (background)`)}`); diff --git a/docs/site/scripts/tutorial-runner/gate.test.mjs b/docs/site/scripts/tutorial-runner/gate.test.mjs index aad5dfb9d..5b8a52055 100644 --- a/docs/site/scripts/tutorial-runner/gate.test.mjs +++ b/docs/site/scripts/tutorial-runner/gate.test.mjs @@ -7,8 +7,8 @@ import test from 'node:test'; import { planGate } from './gate.mjs'; const TOOLSETS = { - breg: { commands: /(^|[^\w-])(bregctl|breg)([^\w-]|$)/mu }, - casework: { commands: /(^|[^\w-])(caseworkctl|casework)([^\w-]|$)/mu, includes: ['breg'] }, + breg: { commands: /(^|[^\w./-])(bregctl|breg)([^\w./-]|$)/mu }, + casework: { commands: /(^|[^\w./-])(caseworkctl|casework)([^\w./-]|$)/mu, includes: ['breg'] }, none: {}, }; diff --git a/docs/site/scripts/tutorial-runner/page.mjs b/docs/site/scripts/tutorial-runner/page.mjs index 427c943bb..bdc5431c7 100644 --- a/docs/site/scripts/tutorial-runner/page.mjs +++ b/docs/site/scripts/tutorial-runner/page.mjs @@ -18,7 +18,8 @@ // nearest sh fence above it. // // A block titled with a file path and marked test-file is the whole file the -// page asks the reader to create or replace in their editor. +// page asks the reader to create or replace in their editor; marked +// test-append, it is what the page asks the reader to add to the end of it. // // test-background="" on an sh fence is a command the page leaves running // while the reader carries on in another terminal: the journey goes on once @@ -41,6 +42,7 @@ const ANNOTATIONS = new Set([ 'test-edit', 'test-excerpt', 'test-file', + 'test-append', 'test-background', 'test-cwd', ]); @@ -103,7 +105,7 @@ function* walk(node) { // { kind: 'skip', line, heading, code, reason } // { kind: 'expect', line, heading, format, text, runIndex } // { kind: 'edit', line, heading, path, before, after } -// { kind: 'file', line, heading, path, text } +// { kind: 'file', line, heading, path, text, append? } // { kind: 'excerpt', line, heading, format, text, runIndex } // { kind: 'excerpt', line, heading, format, text, path } // where runIndex is the index in steps of the fence whose output it checks. @@ -129,6 +131,7 @@ export function readJourney(text) { const edit = annotations['test-edit']; const excerpt = annotations['test-excerpt']; const file = annotations['test-file']; + const append = annotations['test-append']; const background = annotations['test-background']; const cwd = annotations['test-cwd']; @@ -144,6 +147,7 @@ export function readJourney(text) { errors.push(`line ${line}: test-excerpt belongs on a block the page shows, not on an sh fence`); } if (file !== undefined) errors.push(`line ${line}: test-file belongs on a block showing the file, not on an sh fence`); + if (append !== undefined) errors.push(`line ${line}: test-append belongs on a block showing what to add, not on an sh fence`); const step = { kind: skip === undefined ? 'run' : 'skip', line, heading, code: node.value }; if (typeof skip === 'string' && skip !== '') step.reason = skip; if (exit !== undefined) { @@ -174,8 +178,8 @@ export function readJourney(text) { if (exit !== undefined) errors.push(`line ${line}: test-exit applies only to sh fences`); if (background !== undefined) errors.push(`line ${line}: test-background applies only to sh fences`); if (cwd !== undefined) errors.push(`line ${line}: test-cwd applies only to sh fences`); - if ([file, edit, expect, excerpt].filter((value) => value !== undefined).length > 1) { - errors.push(`line ${line}: a block is one of test-file, test-edit, test-expect, or test-excerpt`); + if ([file, append, edit, expect, excerpt].filter((value) => value !== undefined).length > 1) { + errors.push(`line ${line}: a block is one of test-file, test-append, test-edit, test-expect, or test-excerpt`); continue; } if (file !== undefined) { @@ -185,6 +189,13 @@ export function readJourney(text) { else steps.push({ kind: 'file', line, heading, path, text: `${node.value}\n` }); continue; } + if (append !== undefined) { + const path = titleOf(node.meta); + if (node.lang === 'diff') errors.push(`line ${line}: test-append adds whole lines; a diff block is test-edit`); + else if (!path) errors.push(`line ${line}: test-append needs the file path, as title=""`); + else steps.push({ kind: 'file', line, heading, path, text: `${node.value}\n`, append: true }); + continue; + } if (edit !== undefined) { const path = titleOf(node.meta); const sides = node.lang === 'diff' ? diffSides(node.value) : undefined; diff --git a/docs/site/scripts/tutorial-runner/page.test.mjs b/docs/site/scripts/tutorial-runner/page.test.mjs index 885f8f1a3..d7cd597cf 100644 --- a/docs/site/scripts/tutorial-runner/page.test.mjs +++ b/docs/site/scripts/tutorial-runner/page.test.mjs @@ -250,7 +250,7 @@ test('test-excerpt mistakes are errors that name the line', () => { 'line 1: test-excerpt has no sh fence above it to check', 'line 5: test-excerpt belongs on a block the page shows, not on an sh fence', 'line 13: test-excerpt checks the output of the sh fence at line 9, which is skipped', - 'line 17: a block is one of test-file, test-edit, test-expect, or test-excerpt', + 'line 17: a block is one of test-file, test-append, test-edit, test-expect, or test-excerpt', ]); }); @@ -271,7 +271,28 @@ test('test-file mistakes are errors that name the line', () => { 'line 1: test-file needs the file path, as title=""', 'line 5: test-file belongs on a block showing the file, not on an sh fence', 'line 9: test-file takes the whole file; a diff block is test-edit', - 'line 13: a block is one of test-file, test-edit, test-expect, or test-excerpt', + 'line 13: a block is one of test-file, test-append, test-edit, test-expect, or test-excerpt', + ]); +}); + +test('a titled block marked test-append is what the page asks the reader to add to the end of a file', () => { + const { steps, errors } = readJourney( + '## Write\n\nAdd to `q.yaml`:\n\n```yaml title="q.yaml" test-append\n purpose: check\n```\n', + ); + assert.deepEqual(errors, []); + assert.deepEqual(steps, [{ kind: 'file', line: 5, heading: 'Write', path: 'q.yaml', text: ' purpose: check\n', append: true }]); +}); + +test('test-append mistakes are errors that name the line', () => { + const { errors } = readJourney( + '```yaml test-append\na: 1\n```\n\n```sh title="x.sh" test-append\ntrue\n```\n\n' + + '```diff title="a.yaml" test-append\n+a\n```\n\n```yaml title="a.yaml" test-append test-file\na\n```\n', + ); + assert.deepEqual(errors, [ + 'line 1: test-append needs the file path, as title=""', + 'line 5: test-append belongs on a block showing what to add, not on an sh fence', + 'line 9: test-append adds whole lines; a diff block is test-edit', + 'line 13: a block is one of test-file, test-append, test-edit, test-expect, or test-excerpt', ]); }); diff --git a/docs/site/scripts/tutorial-runner/run-tutorial.test.mjs b/docs/site/scripts/tutorial-runner/run-tutorial.test.mjs index ec5578737..9c600bf57 100644 --- a/docs/site/scripts/tutorial-runner/run-tutorial.test.mjs +++ b/docs/site/scripts/tutorial-runner/run-tutorial.test.mjs @@ -512,6 +512,35 @@ test('a test-file block writes the whole file where the reader stands', async () }); }); +test('a test-append block adds its lines to the end of the file where the reader stands', async () => { + const body = + '## Write\n\n' + + fence('yaml title="q.yaml" test-file', 'id: q') + + fence('yaml title="q.yaml" test-append', 'details:') + + fence('yaml title="q.yaml" test-append', ' purpose: check') + + fence('sh', 'cat q.yaml') + + fence('text test-expect', 'id: q\ndetails:\n purpose: check'); + await withPage(body, async ({ page }) => { + const { code, output } = await run([page]); + assert.equal(code, 0, output); + assert.match(output, /appended to q\.yaml/u); + assert.match(output, /tutorial PASS/u); + const plan = await run(['--dry-run', page]); + assert.match(plan.output, /append line 11 \(Write\): q\.yaml/u); + }); +}); + +test('a test-append block whose file does not exist stops the journey instead of creating it', async () => { + const body = '## Write\n\n' + fence('yaml title="q.yaml" test-append', 'details:') + fence('sh', 'echo never'); + await withPage(body, async ({ page }) => { + const { code, output } = await run([page]); + assert.equal(code, 1, output); + assert.match(output, /the file at line 7 \(Write\) failed/u); + assert.match(output, /no file to append to: q\.yaml/u); + assert.doesNotMatch(output, /^never$/mu); + }); +}); + test('a test-file block whose directory does not exist stops the journey', async () => { const body = '## Write\n\n' + fence('yaml title="missing/q.yaml" test-file', 'id: q') + fence('sh', 'echo never'); await withPage(body, async ({ page }) => { diff --git a/docs/site/scripts/tutorial-runner/toolsets.mjs b/docs/site/scripts/tutorial-runner/toolsets.mjs index c77381fdd..7b2950efe 100644 --- a/docs/site/scripts/tutorial-runner/toolsets.mjs +++ b/docs/site/scripts/tutorial-runner/toolsets.mjs @@ -146,6 +146,22 @@ const casework = productToolset({ ], }); +// Registry Relay V2: relay and relayctl. A page serves Relay from a +// background fence, which the runner stops itself, so there is no local +// development session to stop here. +const relay = productToolset({ + label: 'relay and relayctl', + commands: /(^|[^\w./-])(relayctl|relay)([^\w./-]|$)/mu, + binaries: [ + ['relay', 'RELAY_BIN'], + ['relayctl', 'RELAYCTL_BIN'], + ], + cargoArgs: ['-p', 'registry-relay-v2', '--features', 'tooling', '-p', 'registry-relayctl'], + profileVariable: 'RELAY_TUTORIAL_CARGO_PROFILE', + targetName: 'relay-tutorial-source', + sessions: [], +}); + // Evidence: evidence, evidencectl, and evidence-oid4vci. Two things a reader // sets up themselves are set up here instead: // @@ -259,4 +275,4 @@ const none = { }, }; -export const TOOLSETS = { breg, casework, evidence, none }; +export const TOOLSETS = { breg, casework, evidence, relay, none }; diff --git a/docs/site/scripts/tutorial-runner/toolsets.test.mjs b/docs/site/scripts/tutorial-runner/toolsets.test.mjs index 7a3eeb1c9..a0088aabe 100644 --- a/docs/site/scripts/tutorial-runner/toolsets.test.mjs +++ b/docs/site/scripts/tutorial-runner/toolsets.test.mjs @@ -18,6 +18,10 @@ const CASES = { runs: ['evidencectl init .', 'evidence --version', 'evidence-oid4vci --help', 'products/evidence/scripts/check-contracts.sh'], names: ['cd ~/work/evidence', 'ls .evidence/clients', 'cat evidence.yaml', 'ls evidence/'], }, + relay: { + runs: ['relayctl init business-registry', 'relay serve --runtime runtime.yaml', 'relayctl check . && echo ok'], + names: ['cd work/relay', 'ls .relay', 'cat relay.yaml', 'ls relay/'], + }, }; for (const [name, { runs, names }] of Object.entries(CASES)) { diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index 6f326b28c..50673f155 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -16,6 +16,8 @@ standards_referenced: - json-schema - json-ld - shacl +tutorial_test: + toolset: relay --- import QuickstartMeta from '../../../components/QuickstartMeta.astro'; @@ -58,7 +60,7 @@ You need two binaries: `relay` serves a sealed package, while `relayctl` authors project. The installer accepts Linux amd64 only and places both in `~/.local/bin` unless you set `RELAY_INSTALL_DIR`: -```sh +```sh test-skip="installs a release from the network; the runner serves the binaries under test" curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/relay-install.sh | bash relay --version relayctl --version @@ -85,7 +87,7 @@ It reports the files it created. Every `relayctl` command reports in the same sh saying what happened, then the detail underneath. Add `--json` to any of them for the full report as JSON, which carries more than the summary a person reads. -```text +```text test-expect Initialized an authoring project. 7 files written. registry.yaml runtime.yaml @@ -107,7 +109,7 @@ The institution's database is an input, not part of the project. Create a small Create `registry.sql` and put this in it: -```sql +```sql title="registry.sql" test-file CREATE TABLE businesses ( registration_number TEXT PRIMARY KEY NOT NULL, record_revision TEXT NOT NULL, @@ -160,7 +162,7 @@ relayctl inspect registry.sqlite The report opens with a fingerprint of the whole schema, then lists every table, view, index, and column: -```text +```text test-expect Inspected the SQLite structure. 3 objects. fingerprint sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18 @@ -191,20 +193,20 @@ hashed. The same stored schema always produces the same fingerprint, on any mach run, so a value that differs from the one this tutorial prints means your schema text differs, not that your run went wrong. -Copy your own fingerprint out of that report. Writing it into the contract is how you say which +That fingerprint is what you write into the contract, and writing it there is how you say which schema you reviewed. If someone later adds, drops, or retypes a column, the fingerprint changes and Relay refuses to serve rather than guessing whether your review still applies. ## Write the contract The contract names the register, binds the resource to the view, publishes three properties, and -then discloses only two of them. It is shown here one section at a time. Empty `registry.yaml` -first, then append each block below in the order it appears; together they are the file -`relayctl check` reads. +then discloses only two of them. It is shown here one section at a time. Replace the starter +`registry.yaml` with the first block, then add each block below to the end of the file in the +order it appears; together they are the file `relayctl check` reads. Start with what the document is: -```yaml +```yaml title="registry.yaml" test-file apiVersion: relay.registrystack.org/v2alpha1 kind: RegistryContract metadata: @@ -219,7 +221,7 @@ by hand. Next, who the register belongs to and what it claims to be authoritative about: -```yaml +```yaml title="registry.yaml" test-append registry: registryIdentifier: urn:example:registry:businesses name: Business register @@ -242,7 +244,7 @@ honest setting for an alignment you have read but not conformance-tested. Then the three roles that have to be attributable to someone, and where locally defined terms live: -```yaml +```yaml title="registry.yaml" test-append governance: controller: urn:example:authority:registrar publisher: urn:example:authority:registrar @@ -258,7 +260,7 @@ meaningful when someone is named as answerable for it. Next, the vocabularies this contract classifies against: -```yaml +```yaml title="registry.yaml" test-append classifications: privacy: scheme: https://w3id.org/dpv @@ -278,22 +280,22 @@ which is the file the production gate will check later in this tutorial. Now the source, which is where your fingerprint goes: -```yaml +```yaml title="registry.yaml" test-append sources: registry: kind: sqlite profile: snapshot - expectedSchemaFingerprint: sha256: + expectedSchemaFingerprint: sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18 ``` -Replace `sha256:` with the value `relayctl inspect` printed for your -database. Nothing else in the tutorial substitutes for it: a contract carrying anyone else's +This is the value `relayctl inspect` printed above. If yours differed, your schema text differs +from this page's, so write your own value here instead: a contract carrying anyone else's fingerprint is refused with `source.schema_fingerprint_mismatch`. The resource is the largest section, so it arrives in five parts. First its identity and the view it binds to: -```yaml +```yaml title="registry.yaml" test-append resources: - id: registered-business datasetIdentifier: businesses @@ -318,7 +320,7 @@ itself, so the defaults are what you would have to override to publish something Then the four columns that carry record identity rather than content: -```yaml +```yaml title="registry.yaml" test-append recordContext: recordIdentifier: sourceColumn: registration_number @@ -338,7 +340,7 @@ produce, so a value the register invents later is a refusal rather than a surpri Then the properties the contract knows about: -```yaml +```yaml title="registry.yaml" test-append properties: legalName: label: Legal name @@ -368,7 +370,7 @@ publishing it: the next block decides who gets which of them. Then the disclosure and access decision: -```yaml +```yaml title="registry.yaml" test-append disclosureProfiles: public: properties: @@ -391,7 +393,7 @@ deployment, which is why it will need no identity provider later. Then why the register is doing this at all: -```yaml +```yaml title="registry.yaml" test-append processingDescriptions: - id: consultation operationRefs: @@ -410,7 +412,7 @@ can always be traced back to the stated reason for releasing it. Finally, what a caller may read about the register itself: -```yaml +```yaml title="registry.yaml" test-append metadataVisibility: service: public resources: public @@ -434,7 +436,7 @@ exist, and reports the revision of what it compiled. The two counts are the acce configuration key paths: how many keys `registry.yaml` and `runtime.yaml` will each take, not how many yours uses: -```text +```text test-expect Authoring check passed. contract revision sha256: registry key paths @@ -465,34 +467,37 @@ Two of the generated files are review inputs rather than outputs. `generated/rep is the list of source columns and output properties with the handling each one carries. `generated/governance/classification-review-starter.yaml` is a pre-filled review record for exactly that inventory. Its `classificationInventoryDigest` is a digest of your own inventory, -so it changes whenever the contract changes what is classified: +so it changes whenever the contract changes what is classified. For the contract on this page, +the file opens like this: -```yaml +```yaml test-excerpt="generated/governance/classification-review-starter.yaml" apiVersion: relay.registrystack.org/classification-review/v1 kind: ClassificationReview registryIdentifier: urn:example:registry:businesses -classificationInventoryDigest: sha256: +classificationInventoryDigest: sha256:b01c845f9aabc500bae3756dd3daa855c182f540d48ccc5a341ccd2ca25e5e1f method: generated reviewer: urn:example:authority:registrar reviewDate: pending-review status: suggested rationaleRef: pending-review -generatedIdentification: ... ``` +It goes on to a `generatedIdentification` block naming the identification report and the rule +pack that produced the suggested classifications. + `status: suggested` and `reviewDate: pending-review` are the tool saying it has an opinion and no authority. Only a person supplies the rest. ## See what production refuses -```sh +```sh test-exit="1" relayctl check . --production ``` The starter project is refused, and the diagnostics say exactly why. Each one gives its severity, its code, the file and key it is about, and the sentence underneath: -```text +```text test-expect Production check refused. error codelist.unreviewed codelists/record-lifecycle.yaml @@ -518,11 +523,11 @@ Replace `governance/classification-review.yaml` with the review a person signs o `classificationInventoryDigest` out of the starter file `generate` wrote, and use the date on which you actually read the inventory: -```yaml +```yaml title="governance/classification-review.yaml" test-file apiVersion: relay.registrystack.org/classification-review/v1 kind: ClassificationReview registryIdentifier: urn:example:registry:businesses -classificationInventoryDigest: sha256: +classificationInventoryDigest: sha256:b01c845f9aabc500bae3756dd3daa855c182f540d48ccc5a341ccd2ca25e5e1f method: manual reviewer: urn:example:authority:registrar reviewDate: 2026-08-11 @@ -536,7 +541,7 @@ the review binds the report and rule pack that produced them, which the starter Write the rationale it points at, in `governance/classification-review-rationale.md`: -```markdown +```markdown title="governance/classification-review-rationale.md" test-file # Classification review The registrar read `generated/reports/classification-inventory.json` on @@ -548,7 +553,7 @@ review. Mark the legal basis reviewed in `governance/legal-basis.yaml`: -```yaml +```yaml title="governance/legal-basis.yaml" test-file status: reviewed legalBasis: Public inspection of the business register under the Companies Act. ``` @@ -556,7 +561,7 @@ legalBasis: Public inspection of the business register under the Companies Act. And the codelist in `codelists/record-lifecycle.yaml`, which has to list every lifecycle value the view can produce: -```yaml +```yaml title="codelists/record-lifecycle.yaml" test-file id: record-lifecycle version: draft-1 values: [ACTIVE, RETIRED] @@ -569,7 +574,7 @@ Now run the production check again: relayctl check . --production ``` -```text +```text test-expect Production check passed. contract revision sha256: registry key paths @@ -587,7 +592,7 @@ gives will carry this value rather than the earlier one. relayctl package . --output package ``` -```text +```text test-expect Sealed a deployment package. artifacts, files. package version relay.registrystack.org/package/v1alpha3 package revision sha256: @@ -609,7 +614,7 @@ cannot be produced from a revision that would fail `check --production`. `runtime.yaml` from `relayctl init` already points at `registry.sqlite` and `package`, so it needs no edit. Read it once: -```yaml +```yaml test-excerpt="runtime.yaml" apiVersion: relay.registrystack.org/v2alpha1 kind: RelayRuntime server: {bind: "127.0.0.1:8080"} @@ -626,7 +631,7 @@ even for requests that would have been anonymous. Create the audit directory and the integrity key, then start the service: -```sh +```sh test-background="http://127.0.0.1:8080/ready" mkdir -m 700 var export RELAY_AUDIT_KEY="$(openssl rand -base64 32)" relay serve --runtime runtime.yaml @@ -649,7 +654,7 @@ Leave that running and open a second shell in the same directory. curl -s http://127.0.0.1:8080/ready ``` -```json +```json test-expect {"status":"ready"} ``` @@ -659,37 +664,48 @@ Then ask for a record: curl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001 ``` -The answer, abridged to the part this step is about. The real one also carries the URLs a caller -follows to the generated schema, vocabulary, and JSON-LD context: +The answer: -```json +```json test-expect { "data": { - "recordIdentifier": "BIZ-0001", - "revisionIdentifier": "3", - "lifecycleState": "ACTIVE", - "recordedAt": "2026-02-11T09:00:00Z", "authorityIdentifier": "urn:example:authority:registrar", "domainData": { - "legalName": "Aurora Freight Cooperative", - "legalForm": "COOPERATIVE" - } + "legalForm": "COOPERATIVE", + "legalName": "Aurora Freight Cooperative" + }, + "lifecycleState": "ACTIVE", + "recordIdentifier": "BIZ-0001", + "recordedAt": "2026-02-11T09:00:00Z", + "revisionIdentifier": "3", + "schemaReference": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-schema", + "semanticModelReference": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-vocabulary" }, "meta": { "accessProfile": "public", - "disclosureProfile": "public", - "selectedFields": ["legalName", "legalForm"], "contractRevision": "sha256:", - "sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:"}, - "registryIdentifier": "urn:example:registry:businesses", "datasetIdentifier": "businesses", - "entityTypeIdentifier": "business" + "disclosureProfile": "public", + "entityTypeIdentifier": "business", + "family": "consultation", + "links": { + "context": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-context", + "schema": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-schema", + "self": "https://registry.example.invalid/v2/resources/registered-business/records/{recordIdentifier}", + "semanticModel": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-vocabulary" + }, + "operationIdentifier": "registered-business.read", + "pattern": "retrieve", + "registryIdentifier": "urn:example:registry:businesses", + "selectedFields": ["legalName", "legalForm"], + "sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:"} } } ``` `domainData` carries the two disclosed fields. The address is in the view and in the contract, -and it is not here. Every answer also states which contract revision produced it and which +and it is not here. The `links` are the URLs a caller follows to the generated schema, +vocabulary, and JSON-LD context that describe this answer. Every answer also states which contract revision produced it and which source revision it read, so a caller can tell two answers apart without asking you. ## Ask for less, then try to ask for more @@ -700,7 +716,43 @@ A caller can narrow the answer: curl -s 'http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001?fields=legalName' ``` -`domainData` now contains `legalName` alone, and `meta.selectedFields` says so. +`domainData` now contains `legalName` alone, and `meta.selectedFields` says so: + +```json test-expect +{ + "data": { + "authorityIdentifier": "urn:example:authority:registrar", + "domainData": { + "legalName": "Aurora Freight Cooperative" + }, + "lifecycleState": "ACTIVE", + "recordIdentifier": "BIZ-0001", + "recordedAt": "2026-02-11T09:00:00Z", + "revisionIdentifier": "3", + "schemaReference": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-schema", + "semanticModelReference": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-vocabulary" + }, + "meta": { + "accessProfile": "public", + "contractRevision": "sha256:", + "datasetIdentifier": "businesses", + "disclosureProfile": "public", + "entityTypeIdentifier": "business", + "family": "consultation", + "links": { + "context": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-context", + "schema": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-schema", + "self": "https://registry.example.invalid/v2/resources/registered-business/records/{recordIdentifier}", + "semanticModel": "https://registry.example.invalid/v2/artifacts/registered-business--read--access-profile-public-vocabulary" + }, + "operationIdentifier": "registered-business.read", + "pattern": "retrieve", + "registryIdentifier": "urn:example:registry:businesses", + "selectedFields": ["legalName"], + "sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:"} + } +} +``` A caller cannot widen it: @@ -708,7 +760,7 @@ A caller cannot widen it: curl -s 'http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001?fields=registeredAddress' ``` -```json +```json test-expect { "type": "https://id.registrystack.org/problems/registry-relay/request/fields_invalid", "title": "Field selection is invalid", @@ -728,7 +780,7 @@ answer. An unknown record is a separate refusal: curl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-9999 ``` -```json +```json test-expect { "type": "https://id.registrystack.org/problems/registry-relay/consultation/unresolved", "title": "Requested record was not resolved", @@ -751,37 +803,57 @@ Stop the service with `Ctrl+C` and read the first audit line: head -n 1 var/audit.jsonl ``` -The line is one JSON object. Abridged to the fields this step is about: +The line is one JSON object, shown here without its `timestamp_unix_ms`: -```json +```json test-excerpt { "envelope_id": "", "prev_hash": null, "record": { - "operationIdentifier": "registered-business.read", - "resourceIdentifier": "registered-business", - "principalKind": "anonymous", "accessProfile": "public", + "accessRuleRevision": "sha256:", + "contractRevision": "sha256:", + "disclosureHandling": "public", "disclosureProfile": "public", - "selectedProperties": ["legalName", "legalForm"], + "operationId": "", + "operationIdentifier": "registered-business.read", + "operationSurface": "record-read", + "phase": "attempt", + "principalKind": "anonymous", "processingDescriptionIdentifiers": ["consultation"], - "contractRevision": "sha256:", + "processingHandling": "public", + "registryIdentifier": "urn:example:registry:businesses", + "resourceIdentifier": "registered-business", + "rowBoundaryKind": "none", + "schema": "registry.relay.audit/v2alpha1", + "selectedProperties": ["legalName", "legalForm"], "sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:"}, - "phase": "attempt" + "traceId": "", + "transformIdentifiers": [], + "wireFormat": "json" }, "record_hash": "" } ``` -The envelope also carries `timestamp_unix_ms`. The full `record` carries more than is shown: the -registry identifier, the revision of the access rules that were applied, the operation surface -and wire format, the trace identifier shared with the log line, and the handling levels the -answer was released under. None of them is a field value either. +Besides the operation and the properties it released, the `record` carries the revision of the access rules that were +applied, the operation surface and wire format, the trace identifier shared with the log line, +and the handling levels the answer was released under. None of them is a field value. Read what is not there. The line records that an anonymous caller reached the `registered-business.read` operation, and that the contract scoped the answer to the properties `legalName` and `legalForm` under the `consultation` processing description. It does not record -what the values were. +what the values were. Count the audit lines that mention either value Relay released: + +```sh test-exit="1" +grep -c -e 'Aurora Freight Cooperative' -e 'COOPERATIVE' var/audit.jsonl +``` + +```text test-expect +0 +``` + +`grep` exits with status 1 because no line matched. Read the phase too. `attempt` is written before Relay reads the source, so this line proves the request was accepted and scoped, not that it was answered. The matching `terminal` line carries @@ -816,7 +888,7 @@ unset RELAY_AUDIT_KEY | `project.destination_not_empty` from `relayctl init` | The target directory already has files | Initialize into a new directory name. | | `contract.yaml_invalid` from `relayctl check` | A required key is missing, an unknown key is present, or the YAML does not parse | Every key in the contract above is required. `registry.alignmentTargets` needs at least one entry. | | `resource.view_unknown` | `source.view` names something that is not a view in the database | Relay binds to views only. Add a `CREATE VIEW` for the columns you intend to publish. | -| `source.schema_fingerprint_invalid` | `expectedSchemaFingerprint` still holds the `sha256:` placeholder | Run `relayctl inspect registry.sqlite` and paste the value it prints. | +| `source.schema_fingerprint_invalid` | `expectedSchemaFingerprint` is not a `sha256:` digest, usually because it was only partly pasted | Run `relayctl inspect registry.sqlite` and paste the whole value it prints. | | `source.schema_fingerprint_mismatch` | The contract holds a fingerprint from a different schema, usually because the SQL was retyped rather than copied | Run `relayctl inspect registry.sqlite` again and paste the current value. | | `metadata.reference_visibility_invalid` | A public audience cannot resolve the schema or vocabulary the answer points at | Set `metadataVisibility.resources` and `.semantics` to `public` when any access profile is public. | | `classification.review_inventory_stale` | The contract changed after the review was recorded | Run `relayctl generate .` again and copy the new digest from `generated/governance/classification-review-starter.yaml`. | diff --git a/docs/site/src/content/docs/tutorials/query-relay-client.mdx b/docs/site/src/content/docs/tutorials/query-relay-client.mdx index eff84d61f..b8f82639d 100644 --- a/docs/site/src/content/docs/tutorials/query-relay-client.mdx +++ b/docs/site/src/content/docs/tutorials/query-relay-client.mdx @@ -12,6 +12,9 @@ persona: locale: en standards_referenced: - openapi +tutorial_test: + toolset: relay + skip: installs the released client package at the running Relay's version, and needs the Relay the first Relay tutorial left serving --- import QuickstartMeta from '../../../components/QuickstartMeta.astro';