Skip to content

🔒 Make canonical core authoritative for execution #1023

🔒 Make canonical core authoritative for execution

🔒 Make canonical core authoritative for execution #1023

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- run: pnpm install
- name: Lint + Format
run: pnpm lint
test-deno:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.9.5
# `deno task test` covers `scripts/tests/**`, where the runtime drivers
# spawn a literal `bun`. The runner image ships Node but not bun.
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14
- name: Typecheck
run: deno task check
- name: Publish workflow is generated from manifests
run: |
deno task gen:publish-workflow
git diff --exit-code .github/workflows/publish-packages.yml \
|| { echo "::error::publish-packages.yml is out of date — run 'deno task gen:publish-workflow' and commit the result"; exit 1; }
# The npm build packages every workspace dependency of the CLI, and
# `@executablemd/web` carries a generated browser bundle that is not
# committed. Without this the CLI's npm artifact cannot be built at all.
# Preparing is its own step: a build installs nothing (AGENTS.md).
- name: Install dependencies
run: deno task deps
- name: Build the browser bundle
run: deno task build:web
- name: Test
run: deno task test
# The same command the release publishes with, minus --dry-run. Catches slow
# types and unresolvable specifiers before they reach a tag.
jsr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.9.5
- name: Install dependencies
run: deno task deps
# With the generated bundle absent the negated `publish.exclude` glob
# matches nothing and the dry run quietly checks a package the release
# would never upload. Building first makes this validate the real shape.
- name: Build the browser bundle
run: deno task build:web
- name: The workspace is publishable to JSR
run: deno task check:jsr
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.9.5
- name: Install dependencies
run: deno task deps
- name: Build the xmd binary
run: deno task build
- name: Smoke test the compiled binary
run: |
./dist/xmd test smoke-test/README.md \
--component-dir smoke-test \
--component-dir packages/core/components \
--raw
- name: Smoke test attached-service ping-pong with the compiled binary
run: |
./dist/xmd test smoke-test/attached-service-ping-pong.test.md \
--component-dir smoke-test \
--component-dir packages/core/components \
--raw
# The script installs a second copy of core beside a repository component.
# The declaration must cross into the bundled engine so the failure prints
# and execution continues.
- name: Smoke test metadata from a separately loaded core
run: deno run --allow-all --frozen scripts/smoke-loaded-copy.ts
# A built-in resolves from the module graph rather than a search path.
# Only the compiled binary proves it survives `deno compile`. The
# directory target discovers every colocated document beneath core's
# source at once — the built-in components under `components/`, and the
# structural directives, which resolve no file at all.
- name: Smoke test the built-ins with no search path
run: ./dist/xmd test packages/core/src --raw
# An inline root document exercises the compiled module graph the same way
# a file does, and only the binary proves the graph survived `deno compile`.
- name: Smoke test an inline document
run: |
set -eu
test "$(./dist/xmd -e '# Hello' --raw)" = "# Hello"
# Components resolve from the current directory, not from the root's
# identity, so a search path still finds them.
./dist/xmd -e '<Badge />' --component-dir smoke-test --raw | grep -q '✓ verified'
# So does an ordinary relative filesystem read.
./dist/xmd -e '<File path="smoke-test/Badge.md" />' --raw | grep -q 'verified'
# A stray <Else> is a positioned diagnostic the root collects, so this
# renders it and exits 0. The identity is what is being checked.
./dist/xmd -e '<Else>orphan</Else>' --raw | grep -q '(<eval>:1:1)'
# Nothing is written to run a document that was never a file.
before=$(ls -A)
./dist/xmd -e '# Hello' --raw > /dev/null
test "$(ls -A)" = "$before"
# Secret detection is on by default and `--no-secret-detection` is the
# only way off, so both directions belong to the binary rather than only
# to the source runner. The credential is assembled here, so no
# usable-looking literal is committed.
- name: Smoke test the secret-detection opt-out
run: |
set -eu
canary="ghp_$(printf 'abcdefghijklmnopqrstuvwxyz0123456789')"
document="# Smoke
token $canary
"
# Default-on: the run fails, and the credential reaches no output.
if ./dist/xmd -e "$document" --raw > /tmp/on.out 2> /tmp/on.err; then
echo "::error::the compiled binary persisted a credential by default"
exit 1
fi
grep -q 'secret detection rejected content' /tmp/on.err
! grep -q "$canary" /tmp/on.out
# Opted out: the run succeeds, renders the document, and says so once.
./dist/xmd -e "$document" --raw --no-secret-detection \
> /tmp/off.out 2> /tmp/off.err
grep -q "$canary" /tmp/off.out
test "$(grep -cx 'WARNING: secret detection is disabled; credentials may be persisted.' /tmp/off.err)" = "1"
# The value form is refused rather than read as enabled.
if ./dist/xmd -e "$document" --raw --secret-detection=false 2> /tmp/bad.err; then
echo "::error::the compiled binary accepted --secret-detection=false"
exit 1
fi
grep -q 'does not take a value' /tmp/bad.err
# The guide documents this command and its output; running it keeps the
# value-root contract executable rather than described.
- name: Smoke test a value root's JSON result
run: |
test "$(./dist/xmd run smoke-test/value-root.md)" = \
'{"passed":true,"summary":"no findings"}'
# Reading a document's own outline and projecting it happen inside the
# engine, so only the compiled binary proves the catalog and the
# projection survived `deno compile`.
- name: Smoke test document targets
run: |
set -eu
printf '%s\n%s\n' \
'smoke-test/document-targets.md#Alpha' \
'smoke-test/document-targets.md#Beta' > /tmp/targets.expected
./dist/xmd targets smoke-test/document-targets.md > /tmp/targets.out
diff /tmp/targets.expected /tmp/targets.out
./dist/xmd run 'smoke-test/document-targets.md#Alpha' --raw > /tmp/targeted.out
grep -q 'ALPHA_RAN' /tmp/targeted.out
! grep -q 'BETA_RAN' /tmp/targeted.out
# `<WebForm>` is registered by the CLI, so the compiled binary must know it.
# The document fails in preflight, before a listener or a browser, which is
# what makes this runnable on a headless runner: a binary missing the
# registration reports an unresolved component instead, and one that served
# before checking would hang.
- name: Smoke test WebForm registration and preflight
run: |
./dist/xmd run smoke-test/web-form-preflight.md 2>&1 \
| grep -q '<WebForm> schema must be a JSON object'
# The preflight smoke above stops before assets, so it cannot tell a binary
# that embedded the browser bundle from one that did not — and a bundle-less
# compile succeeds silently. This serves a real form and reads the client
# script back over HTTP. Headless is fine: the opener fails and that is a
# warning by design, so the URL is still printed and the form still serves.
- name: Smoke test the compiled binary serving a real form
run: |
set -eu
./dist/xmd run smoke-test/web-form-live.md > /tmp/web-form-live.log 2>&1 &
xmd_pid=$!
trap 'kill "$xmd_pid" 2>/dev/null || true' EXIT
url=""
for _ in $(seq 1 40); do
url=$(grep -oE 'http://127\.0\.0\.1:[0-9]+/f/[A-Za-z0-9_-]+/' /tmp/web-form-live.log | head -1 || true)
[ -n "$url" ] && break
sleep 0.5
done
if [ -z "$url" ]; then
echo "::error::the compiled binary never printed a form URL"
cat /tmp/web-form-live.log
exit 1
fi
curl -fsS "$url" | grep -q '<div id="root"></div>'
bytes=$(curl -fsS "${url}client.js" | wc -c | tr -d ' ')
# The real bundle is ~600 KB of React and RJSF; a placeholder or an
# empty asset would be orders of magnitude smaller.
if [ "$bytes" -lt 100000 ]; then
echo "::error::client.js was $bytes bytes — the binary did not embed the browser bundle"
exit 1
fi
echo "served a real client bundle: $bytes bytes"
# The themed stylesheet is built the same way and by the same task, so
# an embedded font face is the cheapest proof the binary is serving it
# rather than the vendored default.
curl -fsS "${url}theme.css" | grep -q 'font/woff2;base64'
echo "served the themed stylesheet with embedded fonts"
# Terminating with the form still open is the interruption path.
kill "$xmd_pid"
wait "$xmd_pid" 2>/dev/null || true
# Covers the compiled binary relaunching itself as `xmd test-agent`,
# which the source-mode worker command never exercises.
- name: Smoke test the compiled binary as a test agent
run: ./dist/xmd test smoke-test/test-agent/README.md --raw
- name: Smoke test xmd run through ACPX
run: |
./dist/xmd test smoke-test/agent/README.md \
--component-dir smoke-test/agent/components \
--raw
# The host document-filesystem contract, on every target a release ships.
#
# `test-deno`, `test-node`, and `test-bun` run the whole corpus on Linux x64
# and prove the contract holds there. What they cannot prove is that it holds
# on the other four triples: the host adapter's containment is path arithmetic
# plus `realpath`, and both are the platform's — Windows has drive letters,
# UNC paths, junctions, and reparse points that POSIX does not, and the two
# macOS rows resolve `/var` through a symlink that a naive comparison reads as
# an escape.
#
# So this row is focused rather than exhaustive: one suite, plus a compiled
# probe, on each of the five. The compiled probe is the second half of the
# claim — the shipped artifact is a binary, and the adapter reaches
# `node:path`, `node:fs`, and `node:os` through whatever `deno compile` put in
# its graph.
filesystem-contract:
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
target: aarch64-apple-darwin
- runner: macos-15-intel
target: x86_64-apple-darwin
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
- runner: windows-2025
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.runner }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.9.5
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14
# `deno install` rather than `deno task deps`: the task also caches the
# graphs a browser build and a release compile walk, and it reaches them
# by spawning a child — which does not survive the Windows runner's path
# handling. Nothing here builds the bundle or compiles the CLI, so the
# plain frozen install is the whole preparation this job needs.
- name: Install dependencies
run: deno install --frozen
# The compile below runs under `--node-modules-dir=none`, which resolves
# npm packages from the Deno cache rather than from `node_modules`. This
# caches the probe's graph in that mode without touching the layout the
# step above just created.
- name: Cache the probe's graph for a compile
run: >
deno install --entrypoint --node-modules-dir=none --frozen
scripts/files-contract-probe.ts
# Deno first, and the compile with it: `pnpm install` and `bun install`
# each rewrite `node_modules` into their own layout, so a Deno step after
# one of them resolves through links the other pruned (#279).
- name: Host contract under Deno
run: deno test --allow-all --frozen packages/runtime/tests/host-files.test.ts
- name: Host contract as a compiled binary
run: |
set -eu
deno compile --node-modules-dir=none --cached-only --frozen --allow-all \
--output dist/files-contract-probe scripts/files-contract-probe.ts
if [ -f dist/files-contract-probe.exe ]; then
./dist/files-contract-probe.exe
else
./dist/files-contract-probe
fi
- name: Install the Node layout
run: pnpm install
- name: Host contract under Node
run: pnpm exec tsx --tsconfig tsconfig.node.json --test packages/runtime/tests/host-files.test.ts
- name: Install the Bun layout
run: bun install
- name: Host contract under Bun
run: bun test --timeout=300000 packages/runtime/tests/host-files.test.ts
# The same chain `deno task verify:clean` runs locally. It is the regression
# for #279's ownership claim: a build that installs anything moves the
# prepared-state fingerprint, prunes pnpm's links, and fails the resolution
# probe. It duplicates work other jobs do — that is the cost of asserting the
# whole chain end to end rather than each link separately.
composability:
runs-on: ubuntu-latest
# `main` only. The battery this job runs spends ~1,420s on the Deno, Node,
# and Bun suites — which `test-deno`, `test-node`, and `test-bun` already
# run in parallel, on their own runners. Compressing them onto one four-core
# runner made the workflow's critical path 434s → 649s to prove a property
# about installation that most changes cannot break. What this job uniquely
# proves — a clean checkout prepares, builds stay cache-pure and offline,
# and concurrent checks do not dirty what another reads — is worth a
# post-merge run, not a place on every pull request's critical path (#279).
if: github.event_name == 'push'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
# The battery this job runs includes `bun run test:bun`; without the
# runtime that command fails at spawn, in zero seconds, with an empty
# spool.
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.9.5
# Caches the harness's own graph, so it can run --cached-only below.
- name: Prepare this checkout
run: deno task deps
# Clones itself, prepares that clone against a scratch DENO_DIR, then
# fingerprints node_modules, the cache's dependency content, and the lock
# around every build phase — each run offline.
- name: The chain holds from a clean checkout
run: deno task verify:clean
site:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.9.5
- name: Install workspace deps
run: deno install
- name: Check (fmt + lint + typecheck)
run: deno task check
working-directory: site
- name: Production build (clean runner)
run: deno task build
working-directory: site
test-node:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
# `tsc` resolves the literal dynamic import of the generated browser
# bundle, which `deno check` leaves alone, so the typecheck needs the file
# to exist. The specifier stays literal on purpose: `deno compile` follows
# it to embed the bundle in the binary, and an opaque one would ship a
# binary that cannot serve a form.
#
# Before `pnpm install`, not after: `deno task build:web` rewrites
# node_modules into Deno's layout, which strips the packages pnpm placed
# there and fails the typecheck on two dozen unrelated modules. Installing
# afterwards restores pnpm's layout, and the bundle is outside
# node_modules so it survives.
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.9.5
- name: Install dependencies
run: deno task deps
- name: Build the browser bundle
run: deno task build:web
- run: pnpm install
- name: Typecheck
run: pnpm exec tsc --project tsconfig.node.json --noEmit
- name: Test
run: pnpm test:node
test-bun:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14
- run: bun install
- name: Test
run: bun run test:bun
# test:bun never loads packages/cli/src/bun.ts, so it cannot tell whether
# the Bun entrypoint's API.Env providers work. This document drives a
# <TestAgent> scenario, which makes the parent relaunch bun.ts as
# `test-agent` through the command it builds — the only check that
# exercises that relaunch.
- name: Bun entrypoint smoke
run: bun run packages/cli/src/bun.ts test smoke-test/test-agent/README.md --raw
green:
needs:
[
lint,
test-deno,
jsr,
smoke,
filesystem-contract,
composability,
site,
test-node,
test-bun,
]
if: always()
runs-on: ubuntu-latest
steps:
- name: Every CI job succeeded
env:
RESULTS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$RESULTS" | jq -r 'to_entries[] | "\(.value.result)\t\(.key)"' | sort
unproven=$(echo "$RESULTS" | jq -r '
to_entries[]
| select(.value.result != "success" and .value.result != "skipped")
| .key')
if [ -n "$unproven" ]; then
echo "::error::CI is not green: $(echo "$unproven" | tr '\n' ' ')"
exit 1
fi