- Status: Current
- Scope: How a version of executable.md ships: tagging, binary release, and npm/JSR package publishing.
Every merge to main updates a rolling draft release whose notes list the
PRs merged since the last published release. A release is triggered by a
maintainer publishing that draft via the GitHub Releases UI, which creates a
vX.Y.Z tag from main. No workflow creates tags.
The tag starts two workflows: release.yml compiles the xmd binaries, attests
each one, and attaches them to the release; publish-packages.yml publishes
every @executablemd/* package to npm (primary) and JSR (secondary). npm publishes
per package; JSR publishes all workspace packages together. Binaries come
first: publish-packages.yml publishes nothing until release.yml succeeds,
so npm versions never exist without matching binaries. Both workflows build
from the tag's commit.
sequenceDiagram
actor M as Maintainer
participant DR as draft-release.yml
participant GH as GitHub
participant R as release.yml
participant PP as publish-packages.yml
participant PO as publish-one.yml (per package)
participant NPM as npm
participant JSR as JSR
GH->>DR: push: main (PR merged)
DR->>GH: update draft release changelog
M->>GH: publish the draft → tag vX.Y.Z from main
GH->>R: push: tags v*
GH->>PP: push: tags v*
R->>R: validate tag matches every manifest
R->>GH: compile and attest xmd per target,<br/>attach binaries + checksums to the release
PP->>PP: validate tag matches every manifest,<br/>wait for release.yml to succeed
PP->>PO: one call per package,<br/>needs-ordered (deps first)
M-->>PO: approve npm-publish environment
PO->>NPM: npm publish --access public (OIDC,<br/>skipped if version already published)
PP->>JSR: deno publish (whole workspace,<br/>already-published members skipped)
A workspace member is publishable when its deno.json declares a name
under @executablemd, its package.json does not declare "private": true,
and both manifests are present. That is the whole definition, and everything
that selects release members uses it: the publish-workflow generator, the npm
builder, deno task bump, both tag-time gates and the pull-request check.
Membership is therefore the workspace and not a list — a new package joins by
existing. packages/test-support is the one private member, and it stays at
0.0.0.
Identity comes from deno.json because that is the name the packages actually
publish under. Nothing requires the two manifests to agree about it, so a
selector reading package.json's name instead would admit a different set, and
a set the gates disagree about is a tag that publishes one half of a release.
Both manifests must be present for the same reason: no deno.json is no JSR
entry, and no package.json is no npm package.
Every publishable member declares the same version in both its manifests.
packages/cli/src/cli.ts imports packages/cli/deno.json and reads version
from it, so the compiled binary reports the manifest version — the manifests
are the single source.
Three checks hold that. scripts/lib/version-lockstep.ts answers it for a pull
request, where the drift is introduced and where it is still cheap: it reports
every manifest that declares a different version from its siblings, and every
bun.lock workspace entry that has gone stale or missing. The other two are the
tag-time gates — release.yml before the binaries, publish-packages.yml
before the packages — and each refuses a tag every publishable manifest does not
declare, so the tag and the manifests cannot diverge.
A gate that reads fewer manifests than the other is the failure this
arrangement exists to prevent. v0.13.0 published its binaries and none of its
packages because release.yml read packages/cli/deno.json alone: the release
branch had been bumped before packages/git existed, cli matched the tag, and
only the package gate noticed that git did not.
To cut a release: run deno task bump <version> (stamps every manifest),
restamp the workspace versions in bun.lock, merge to main, then publish the
draft release — its tag follows the manifests (§3).
bun.lock records a version for every workspace member, and the bump task
does not touch it — bun install will not restamp those entries either, since
they already satisfy the lockfile. Left alone they keep the previous release's
number, which the lockstep check reports, so restamping them is part of cutting
the release rather than a tidying step afterwards. Only the members whose name
is an @executablemd package change; an unrelated dependency that happens to
share the old version number must not.
The bump touches nothing else. PR Review and Repo Analysis prepare and build
the checked-out revision with deno task setup and deno task build, then run
./dist/xmd. They do not
install the latest published release, so a review always understands the
documents at the revision it checks.
-
draft-release.yml(push: main): maintains the rolling draft release with release-drafter (config:.github/release-drafter.yml). Each merged PR appends a changelog line; publishing the draft cuts the release. After every merge the workflow syncs the draft with the manifests: when the manifest version is already released, the draft's notes carry a warning banner saying the manifests need bumping; once bumped, the banner clears and the draft's tag and title default to the guard-passingv<version>. -
release.yml(push: tags v*): a preflight job validates the tag against both manifests of every publishable member (§2); on mismatch it flags the just-published release on the Releases page — caution note in the notes, a failed title, and the prerelease marker — so a forgotten bump is visible where the release was made, then refuses to build. On a valid tag it compilespackages/cli/src/compiled.tsper target throughscripts/compile.ts, which supplies every embedded asset from the one canonical list (§10), and attaches the binaries and sha256 checksums to the tag's GitHub Release. That module is the compiled-binary entrypoint: it installs theAPI.Env.commandadapter that relaunches the binary as itself, which a source entrypoint cannot do. The matrix supplies only what differs per job:--target ${{ matrix.target }}and--output dist/${{ matrix.artifact }}. On the one member the runner can execute —x86_64-unknown-linux-gnu— the job then runsscripts/smoke-documentation.tsagainst the binary it just built, before it attests (§10). Between that compile and the upload, each matrix job attests itsdist/${{ matrix.artifact }}with a commit-pinnedactions/attest, so GitHub publishes build provenance for the exact bytes the job produced — one attested subject per target, from one shared step. Thereleasejob needs the wholebuildmatrix, so a failed attestation withholds every binary instead of releasing an unattested one. -
review.ymlandrepo-analysis.yml: install the repository-pinned Deno and pnpm actions, rundeno task setupanddeno task build, and execute the checked-out./dist/xmdagainst the checked-out Markdown root. Neither passes a component include: the review and repository-analysis component graph is declared by the code-review package embedded in that binary (§10), so the components a review runs come from the installation rather than from the checkout being reviewed. Their CI roots use<Output>error mode, so execution failures fail the workflow through the CLI exit status. Journals and reports are uploaded withif: always(); Actions does not interpret journal records or rendered error markers. -
publish-packages.yml(push: tags v*): GENERATED byscripts/gen-publish-workflow.md— an executable markdown document that expands the rootworkspaceentries (including one-level globs such aspackages/*) and derives one job per publishable member (§2) — never edited by hand. A member held back by"private": trueappears in no npm job, so a package can land its foundation onmainbefore it is ready to publish; clearing the flag adds it back on the next regeneration. Such a member also declares nodeno.jsonnameand noexports, sodeno installwarns about neither an unpublishable name nor a missingexports, anddeno publishfinds no JSR entry to publish either; both fields land in the same PR that clears the private flag. The two conditions are independent rules, and no repository member reaches the second one on its own, soscripts/tests/publish-workflow-generator.test.tsruns the generator over a fixture member that holds a full JSR identity and declares"private": true. Rundeno task gen:publish-workflowafter adding/removing a workspace package or changing its@executablemddependencies; CI fails if the committed file is stale. Itsversionjob validates the tag against every manifest and polls therelease.ymlrun for the same commit, failing if the binary build fails. It then fans out onepublish-one.ymlcall per package, ordered withneeds:so dependencies publish before dependents (leaves run in parallel), plus onejsrjob for the whole workspace.That ordering is a publication guarantee, not a build one. Each job builds its own closure from the tag's checkout, so no job waits for another's artifact to reach npm. What the edges still buy is that a failed upstream publish withholds every dependent: whatever npm ends up holding is dependency-closed, and never a dependent whose dependency never published.
-
publish-one.yml(workflow_call, inputspackage/version): builds one package with dnt (scripts/build-npm.ts) and publishes it to npm. Runs in thenpm-publishenvironment. The build is one attempt: it constructs its own closure from the checkout, so there is no registry propagation left for a retry to wait out. npm publishing is idempotent: it skips an already-published version. Library entry points come from the member'sdeno.jsonexports; an executable comes from itspackage.jsonbin, so the npm CLI shipspackages/cli/src/node.tswhile JSR gets the Deno entrypoint.
No published package may depend on a jsr: specifier. dnt rewrites one into a
@jsr/* npm dependency, which the default registry does not serve, so every
consumer would need a @jsr:registry mapping in their own npm configuration —
something a package cannot ship, because npm strips .npmrc from published
tarballs. Verify it on the emitted manifest: after a scripts/build-npm.ts run,
packages/<name>/npm/package.json declares no @jsr/* dependency.
One build serves both purposes, and it runs in two phases.
Phase 1 builds the local closure. The requested package's internal
dependencies are built first — depth-first over the workspace:* dependencies,
each at most once, so a diamond shares one artifact — and are handed to dnt as
absolute file:<package-dir>/npm ranges naming the artifacts this same
invocation produced. The install and the type check therefore resolve every
sibling from the working tree, which is what a branch changing a shared API
needs, and no build asks npm for a package from its own release.
Phase 2 finalizes every manifest together, once the last dnt call has
returned. Each internal file: range becomes the sibling's ^<version>, taken
from the workspace manifests rather than from the version on the command line.
It is the whole closure at once and not each package as its own build
finishes. A sibling rewritten early describes a dependency only the registry
could supply, and whether the rest of the closure survives that depends on how
npm installs a local directory and on what that sibling's own build left in its
node_modules: nothing goes wrong under the default symlink layout, while under
install-links=true the dependent's install fails. Finalizing at the end is
what makes the build independent of both. No install or type check runs after
finalization begins.
The result is publishable, or the build fails. Before reporting success the
builder inspects every generated manifest and refuses a dependency range
beginning with workspace: or file:, and any string naming the checkout's
path or file: URL. A known internal range is rewritten; anything else local is
an unexpected dependency, and normalizing it away is exactly the edit that would
make an unpublishable artifact look fine. An internal dependency that no
workspace member declares is refused by name before its dependent is built —
there is no registry fallback, because falling back is how a misspelled member
would quietly restore the wait this design removes.
There is no separate verification mode: the artifact a developer builds is the artifact a release publishes.
DNT_SKIP_INSTALL=1 skips the install and the type check, for exercising the
tooling. It covers only packages that declare no workspace:* dependencies. dnt
emits through TypeScript, which resolves from the output directory, so the
install is what supplies a sibling's declarations; without it a sibling resolves
to its workspace source and lands in the package. The builder therefore refuses
a package that declares one, naming the dependencies and leaving the output
directory empty. Release workflows never set the variable.
deno publish runs once from the repo root and publishes every workspace member
together. Members reference each other by bare name (@executablemd/core) with
no import-map entry: Deno resolves those through workspace membership, and
deno publish records them as jsr: dependencies. A member manifest must not
map a sibling to a relative path — a path that leaves the package root resolves
against the publish root and the module graph fails to build.
A JSR failure fails the release. The job needs no idempotency guard of its own:
the pinned Deno v2.9.1 queries the registry and skips each workspace member JSR
already carries at that version, member by member. A rerun after a partial publish
therefore completes exactly the members that are missing, and a rerun after a
complete publish exits 0 without republishing. Never gate the job on one
package's existence — whether core is published says nothing about the other
six.
deno task check:jsr runs the same command with --dry-run and is a required
CI job on every PR (§3, ci.yml). It enforces JSR's fast-check rules, so every
symbol in a package's public API needs an explicit type annotation and no export
may be a destructuring. It does not exercise the publish-time module-graph
rewrite, which only a real publish reaches.
publish-one.yml authenticates to npm with GitHub Actions OIDC trusted
publishing; the repo holds no npm token. Each package's trusted publisher on
npmjs.com (Settings → Trusted Publisher → GitHub Actions):
| Field | Value |
|---|---|
| Organization or user | taras |
| Repository | executable.md |
| Workflow filename | publish-packages.yml |
| Environment name | npm-publish |
| Allowed actions | npm publish |
npm validates the calling workflow's filename for workflow_call, not the
reusable publish-one.yml. Binding the environment name makes npm reject OIDC
tokens minted outside the gated environment.
That same trusted publisher is what makes the packages carry provenance: npm
generates it automatically for a public package published from a public
repository over OIDC, which is exactly this path. npm publish --provenance is
therefore intentionally absent — the flag would add nothing the identity npm
already validated does not supply.
The binaries carry provenance of their own (§3), attached to GitHub rather than to a registry. A consumer verifies a downloaded one against this repository:
gh attestation verify ./xmd-<target> -R taras/executable.md- The
npm-publishenvironment requires reviewer approval and deploys only forv*tags, and every npm trusted publisher is bound to it (§4). Thejsrjob runs in it too, so both registries admit the same publishers. - Rulesets require PRs into
mainand restrictv*tag creation to maintainers.
A tagged release cannot publish a package that does not yet exist on npm and
carry a trusted publisher, and npm documents those two in that order: "the
package you're configuring must already exist on the npm registry"
(npm help trust, npm 11.17). The workflows carry no npm token, so the first
record is made by hand, once.
That documented order is the supported one, and this procedure follows it. It is
not a claim about what the registry will refuse: #276 records npm trust github
succeeding for @executablemd/web while the registry still answered 404 for the
package itself. What that configuration then did through a real first tagged
publish was never established, so nothing here relies on it.
components/BootstrapNpmPackage.md is that procedure. It publishes an empty
0.0.0-bootstrap.0 reservation under the bootstrap dist-tag, then configures
GitHub Actions as the package's trusted publisher with the values in §4's table.
It never publishes latest — the first tagged release does that.
The reservation is empty because nothing about it needs to be otherwise. It
exists to make the name resolvable so npm trust can be configured against it,
and an empty artifact carries no dependency, no entry point and no claim about
the package's contents for latest to inherit by accident.
It is not a workaround for a build that cannot run. A package declaring
workspace:* dependencies builds its siblings from the same checkout (§3), so
its first artifact can be built before any of them is published. That was not
true when this procedure was written, and the belief that it was is what made
the reservation look forced rather than chosen; #152 records the version of this
step that assumed it.
0.0.0-bootstrap.0 is never a release version, so publish-one.yml's
already-published guard never matches it: the first tagged release publishes its
own version normally and npm points latest at it. The bootstrap dist-tag
stays where it is.
- Create its directory under
packages/with adeno.json(name under@executablemd) and apackage.jsondeclaring its dependencies (workspace:*for internal siblings). The rootdeno.jsoncovers it through thepackages/*workspace glob, so membership needs no edit. Rundeno task gen:publish-workflowand commit the regenerated orchestrator. - Reserve the name and install the trusted publisher, as an
@executablemdscope owner on npm 11.15 or newer:npm login deno task xmd run README.md#Bootstrap --props-package packages/<name>
README.md#Bootstrapis the entry point; it invokescomponents/BootstrapNpmPackage.md, which is also runnable directly. Naming no package reserves nothing, so the target composes into a whole-README run without reaching the registry. Run it without--journaland without--verbose. The document elicits a one-time code and interpolates it into the publish and trust commands; a journal file records both the answer itself and those commands, and--verbosereports the same records to stderr. Rendered output carries only what a command printed, so neither flag is needed and both would persist the code. - Create the package on jsr.io under the
@executablemdscope and link it to this repository, before the first tagged release that includes it.deno publishfails for a package that does not exist on JSR, and the JSR job publishes the workspace as a unit — so one uncreated package fails the release for every package.
The document verifies before it works, and renders what it verified:
- the operator is logged in, from
npm whoami; - npm is 11.15 or newer, from
npm --version; {props.package}is a workspace member whosepackage.jsonanddeno.jsonboth exist and agree on an@executablemdname.
npm supplies those values and the document compares them, so a failure stops the run before the registry is inspected, before the artifact exists, and before a one-time code is requested.
The reservation artifact is written as Markdown rather than assembled by a
shell: <File> writes the package.json and README.md into a temporary
working directory, and npm pack --dry-run previews that exact directory before
anything is published from it. Every registry command — both reads and both
writes — is an exec as="…" block, so what it settled to, exit code and both
channels, is bound as a value: npm reports a package it does not carry by
exiting non-zero, and here that is an answer rather than a failure. The
comparison happens in the document and <If> selects what happens next, so what
the document decided is readable in what it rendered. That covers a refused
write as much as a 404: a publish npm rejects is reported in npm's own words,
where silent would have hidden both channels and left an exit code with the
reason discarded.
No read decides whether to publish. A read is a claim about a moment that has
passed, and npm accepts a write before its package reads report it — so a
reservation a read finds missing may have been made since, and one it makes may
not be visible yet. Both failure modes were observed bootstrapping
@executablemd/workflow: a completed run reported as failed because the read
back still answered 404, and the re-run that followed planning a publish for a
version that already existed. The placeholder is therefore always offered, and
npm's answer settles it. npm 11.17 asks the registry for the package's versions
with preferOnline before uploading anything and refuses over one it already
carries with an uncoded error carrying You cannot publish over the previously published versions: <version> (lib/commands/publish.js); a registry that
answered that check staler than itself refuses the upload instead, as
EPUBLISHCONFLICT. Either is the answer "already reserved", from the party that
decides it. Any other refusal is a failure and is reported as one.
The reads that remain are guards, not decisions. A package carrying real
versions is not one to offer a placeholder to — a mistyped --props-package is
how that happens — so the versions and dist-tags are read before the code is
requested and again after it, and either read refuses. Neither selects what
happens next.
The one-time password is requested after the preview succeeds and before the
trusted publisher is read. npm trust list needs a code to answer at all: npm
makes a package's trusted publisher readable only to someone who could change
it, so what a package already trusts cannot be established without one. Every
run that gets past the version reads is therefore asked for a code — including a
re-run against a package that is already reserved and already trusted, which
reads, reports the end state, and writes nothing. The artifact is built on every run, because
every run offers it — the preview shows what will actually be attempted.
Nothing reads the package back to confirm it. Each write already answered, and that answer came from the party that decides it, so a package read could only disagree with what just happened. What is read back is the trusted publisher, which npm serves from an authenticated path rather than the packument cache and which answered truthfully while the package reads were still behind — it carries the id that revokes the configuration, which the document prints.
Re-running is safe, and the two halves are skipped independently:
- a package already at
0.0.0-bootstrap.0is offered the placeholder again and npm refuses it, which is how the run learns the reservation stands; - a trusted publisher already matching §4's table exactly — GitHub Actions,
taras/executable.md,publish-packages.yml,npm-publish, and publish as its only permission — is not created again.
Neither half is repaired. The registry "only supports one configuration per
package… If you attempt to create a new trust relationship when one already
exists, it will result in an error" (npm help trust, npm 11.17), so a
configuration that differs from §4's table stops the document, which reports
what it found and revokes nothing; replacing one is a deliberate
npm trust revoke by a scope owner, and the document prints that command with
the trust id it read. A version other than 0.0.0-bootstrap.0, or a bootstrap
dist-tag pointing elsewhere, stops it the same way.
The version refusal happens before the code is requested; the trust refusal
happens after it, because the answer it turns on cannot be read without one.
Neither writes: the trusted publisher is read before the placeholder is
published, so a refusal leaves the registry exactly as it was. The versions are
read a second time after the prompt, because the operator is away generating a
code while they can change. The trusted publisher is not — its one read already
happened after the code, with only this document's own placeholder written
since, so a second read would have no away-time to cover. A publisher configured
in that gap makes npm trust github fail, which the run reports rather than
replaces.
Re-run failed jobs on the tag's own workflow run. Publishing skips an
already-published version, so re-runs and re-tags of hand-bootstrapped
versions succeed. This holds per package on both registries: npm's guard runs
per publish-one.yml call, and deno publish filters already-published
workspace members individually — so a rerun after a partial publish picks up
only what is missing. No dispatch path publishes outside a tag.
A rerun of a build job compiles and attests again, and the release cannot
publish until that attestation succeeds. The guarantee is that the released
digest carries valid provenance from this repository, not that it was attested
exactly once — GitHub accepts more than one attestation for a subject, and
verification is satisfied by any valid one.
packages/web's browser client (packages/web/client/**) bundles through
scripts/build-web-client.ts (deno task build:web) into
packages/web/generated/client-bundle.ts — gitignored, never committed. The
Deno test suite (scripts/tests/build-web-client.test.ts) bundles and
inspects the real output, asserting determinism, absence of eval and
external-asset paths under the fixed CSP policy, that a build leaves the
installed dependency tree and its manifests exactly as it found them, and that
the build script writes its module wherever --out names. The suite writes to
scratch paths of its own rather than to the generated path: that path is read by
whatever else is running while the suite runs (AGENTS.md), and a test that took
a turn at writing it would be a race rather than a check.
The generated module is published through replaceThroughStaging() — staged
beside the target under a name carrying a UUID, then renamed over it. Deno, Node
and Bun resolve through that path while a build republishes it, and a direct
write exposes a truncated file for as long as the write takes. A reader
therefore sees the complete old module or the complete new one, never absence or
partial text; the staged file belongs to the invocation on every exit path, so a
halted or failed build leaves nothing beside the module. deno task verify
proves this with live readers rather than by inspection.
A build installs nothing: deno task build:web runs under node-modules and
cache modes that cannot create, relink, or fetch, and refuses on an unprepared
worktree (scripts/preflight.ts). release.yml compiles the binaries without
going through deno task build, which compiles for the host — so the same
--node-modules-dir=none --cached-only --frozen travels with the shared compile
command instead (§10). scripts/tests/publish-workflow-membership.test.ts
asserts those flags on that command, asserts that no workflow names the xmd
entrypoint on a deno compile line of its own, and keeps the original
per-invocation flag scan for every other entrypoint a workflow compiles.
Preparation comes in two kinds, and only one of them is anybody's routine.
Host preparation — deno task deps, and deno task setup around it — caches
what the machine it runs on needs, and is what a developer and every ordinary CI
job runs. Target preparation — deno task deps:target <target> — caches the
selected target's dependency graph, whether or not that target happens to match
the runner's own platform, and exists solely for a release job and for
verify:clean's representative target. A local setup never performs it, so it
never downloads five platforms' packages.
--cached-only holds across the matrix because each job prepares its own
target. deno compile --target resolves the npm packages of the platform it
compiles for, which host preparation never cached — measured, a
x86_64-unknown-linux-gnu compile on a host-prepared tree fails on
@msgpackr-extract/msgpackr-extract-linux-x64. So every matrix job runs
deno task deps:target ${{ matrix.target }} between the bundle build and the
compile. That step is deno install --entrypoint --node-modules-dir=none --frozen with the target's OS and architecture: it populates the job's Deno
cache and neither replaces nor relinks node_modules. The target-to-platform
mapping is contractual and lives in scripts/lib/release-targets.ts;
scripts/tests/release-targets.test.ts holds it to the workflow matrix by exact
set equality, checks the preparation argv per target, requires preparation to
precede compilation inside the build job, requires the compile to keep
--target, --cached-only, and --frozen, and requires the documentation
smoke to sit between the compile and the attestation on the runnable member.
deno task verify:clean exercises that same sequence against a prepared clone,
offline, for the representative x86_64-unknown-linux-gnu — proving the compile
fetches nothing and changes neither dependency layout nor the lock, and that
target preparation itself leaves the host tree and lock untouched. It closes
with the concurrent interference proof: deno task build:web republishing the
generated module while Deno, Node and Bun resolve and read through the same
node_modules and import that module, followed by one comparison of tracked
files, node_modules and deno.lock. It runs no application suite — those are
the dedicated CI jobs green requires (AGENTS.md, #546).
Host preparation is deno task deps, which owns node_modules/, the cached
module graphs, and the sideEffects fact. Every job that builds, packages,
publishes, or releases the workspace runs it and then deno task build:web; a
release job adds target preparation on top, for the target it is compiling:
ci.yml'stest-denojob, because the suite builds the CLI's npm artifact and dnt packages@executablemd/webalong with it.ci.yml'stest-nodejob, beforepnpm installrather than after: the Node typecheck resolves the module's literal dynamic import, anddeno task depsrewritesnode_modulesinto Deno's layout, so pnpm's install has to come last. The build between them changes nothing there.ci.yml'sjsrjob, so the dry run validates the artifact the release uploads rather than a bundle-less variant of it.ci.yml'ssmokejob, through the rootbuildtask, which chains the bundle build ahead ofdeno compile.publish-one.yml, unconditionally rather than only forpackages/web— dnt builds a package's workspace siblings too.publish-packages.yml'sjsrjob, generated fromscripts/gen-publish-workflow.md.release.yml, before the matrix compile step. It invokesdeno compiledirectly rather than through the root task, so it needs its own step.
Neither publishing nor compiling reports the omission. deno publish finds the
negated glob matching nothing and says so quietly; deno compile succeeds and
produces a binary that runs, serves a page, and cannot load its client. The
ordering is therefore asserted by test — scripts/tests/publish-workflow-generator.test.ts
for the JSR job and scripts/tests/publish-workflow-membership.test.ts for the
release workflow — and ci.yml's smoke job serves a real form from the compiled
binary and reads the client asset back over HTTP, which is the only check that
can tell an embedded bundle from a missing one.
That job is where every claim about the compiled binary is proved, because
each one depends on deno compile having kept something: scripts/smoke-foreground.ts
for a foreground command's live output and exit status, scripts/smoke-loaded-copy.ts
for a declaration crossing into the bundled engine, and scripts/smoke-fetch.ts
for <Fetch> — a core component resolving from the module graph, requesting
through the contextual Fetch adapter, and binding a detached response, against a
loopback server the script owns. scripts/tests/ci-workflow.test.ts holds the
job to naming them, so removing a script is not a proof silently withdrawn.
A job that skipped the build fails with a module-not-found naming a path nobody
chose, which is why packages/web/src/assets.ts reports the missing bundle by
naming the command instead.
The module is gitignored, so deno publish excludes it by default and then
refuses the package: it sits in the module graph and would not exist at runtime.
packages/web/deno.json un-excludes it with a negated publish.exclude glob, so
the published package carries the bundle it needs while the repository still does
not track it.
Tree-shaking the dead runtime-validator path out of the bundle needs
@rjsf/validator-ajv8 declared side-effect-free, which the package ships
without. deno task deps records that fact, and a build asserts it and fails
pointing at deno task setup when it is absent.
It is recorded on every installed copy, because the tree is a union of two
stores and which copy the bundler reads depends on how it resolves: Deno's
automatic mode reaches the root store, while --node-modules-dir=manual
resolves the way Node does and reaches pnpm's copy under
packages/web/node_modules — the difference between a 606 KB tree-shaken
bundle and a 734 KB one carrying new Function. pnpm install restores its own
copy from its store, so deno task setup records the fact after it rather than
before.
scripts/lib/staged-write.ts writes through a staged file named for the
invocation and renames it into place, so a concurrent preparation cannot delete
another's staging, a cancelled one waits for its own in-flight write before
removing it, and a reader sees either every old byte or every new one.
Bundling is Deno-only; the shape of the generated module is not. Its serializer
(scripts/lib/web-client-module.ts) touches no host, so
scripts/tests/web-client-module.test.ts loads generated modules and checks
their round-trip and byte-length contract under Deno, Node, and Bun.
A publishable @executablemd/web is an atomic configuration state: public
deno.json identity and exports, private cleared from package.json,
build:web run immediately before both JSR and npm packaging, and explicit
inclusion of generated/client-bundle.ts in both published artifacts. The
configuration elements change atomically; the package is never published
without its browser asset and never published while private.
xmd upgrade installs a published release into a standalone binary
(xmd upgrade), so what a release publishes is
also what a self-upgrade depends on:
packages/cli/src/release-targets.tsowns the platform, architecture, target triple and exact artifact name of every published target.release.yml's matrix andscripts/lib/release-targets.tsare held to that one table byscripts/tests/release-targets.test.ts, so the release and a self-upgrade can never choose different artifacts.- Every published target has exactly one artifact, named exactly as that table
names it,
.exeincluded for Windows. checksums.txtcarries exactly one SHA-256 entry per artifact, in GNUsha256sumformat, whose filename is the artifact's exact basename.- A release is not a valid self-upgrade source until every artifact and the
checksum set are published.
release.ymlgenerates the checksums and publishes them with the binaries in the same step, andfail_on_unmatched_filesrefuses a partial set. - A missing artifact for the current target, or a checksum set that does not name it exactly once, fails the upgrade closed. There is no fallback to another target, another release, or an unverified download.
A document-backed command executes first-party Markdown through the ordinary XMD
engine rather than a TypeScript implementation. A package declares which Markdown it
ships by putting it in src/documents/; every other Markdown under src/ —
test documents, scenario fixtures — stays out of the product. xmd plan is
the first such command, and it ships two: the checked-in Markdown is the
deployed artifact and the single source of truth, not a generated string mirror
of one.
packages/cli/src/documents/Plan.mdis the packaged<Plan>Component, which owns the Plan authorship workflow — every Prompt, the checking and repair loop, the review, the revisions, the approval and every ending. It is the public<Plan>component, so an ordinary document reaches the same bytes the command does.packages/cli/src/documents/plan-command.mdis the command's root, and only its adapter: it projects the request into<Plan>and returns what comes back.
There is one Component source, and neither a generated TypeScript copy of it nor a second Markdown implementation exists. That is what makes "the same workflow, whichever surface asked" a fact about the file rather than a claim about two of them.
xmd upgrade is the second such command, and
packages/cli/src/documents/upgrade-command.md is the streaming text root it
executes. It ships through the same directory-wide mechanism and is read through
the same package-relative lookup; nothing about it is special-cased.
The command locates it from its own module URL — never from the contextual working directory, and never through the component search path. Both are answerable by whatever directory a person is standing in, and which Component the command runs is not a thing a repository file may decide.
Every build therefore keeps the asset beside its module, at the same relative path:
- source checkout — the file as committed;
deno compile— embedded by one--include packages/<name>/src/documentsper package, named once inscripts/lib/compile.tsand therefore present at every compile site (§10);- npm (dnt) — copied by
scripts/build-npm.ts, which copies each package'ssrc/documents/intoesm/src/documents/, preserving relative location. dnt emits the module graph and nothing else, so an asset no TypeScript imports is absent from the published package unless the build copies it. That failure is invisible under Deno and reaches only Node and Bun.
A missing asset fails loudly, naming the path it looked at, rather than selecting different behavior.
The checks that hold this together, each proving a different build:
packages/cli/tests/packaged-document.test.tsreads the document from a temporary working directory and compares it to the committed bytes. It runs under Deno, Node and Bun, which is what makes it evidence rather than one runtime's opinion.scripts/tests/cli-npm-bin.test.tsbuilds the real package, asserts every emittedesm/src/documents/asset is byte-identical to the source, checks that the emitted core and runtime manifests publish./apiand that the module behind it was emitted, runs the built bin with a--pluginan operator would write, and then asks the built bin — from a directory that is not the package — which Plan<Plan>Component source it would let a document write. The answer carries the origin and the SHA-256 of those bytes, so a build that shipped different ones, or none, answers differently here rather than at a person's firstxmd plan.scripts/tests/plan-component-compiled.test.tsasks the same question of the compiled binary, which has no checkout to fall back to. It runs in thesmokejob, beside the other suites whose subject isdist/xmd.scripts/tests/plugin-compiled.test.tsasks the binary to load a module that is not embedded in it — an operator's--plugin— from a directory that is not the checkout. A binary with nonode_modulesand no module graph to add to either loads an external ESM module and runs it, or it cannot, and nothing else can tell. It also proves the binary describes only the engine's own language until a Plugin is named, and gains the review graph when one is. It runs in thesmokejob for the same reason.scripts/tests/packaged-document.test.tsholds the canonical compile inputs to the document directories that exist and are not empty, because which packages ship documents is the one thing no build discovers for itself.
Adding another packaged document to a package that already ships one needs no
build change at all: build-npm.ts copies the directory and the canonical
inputs name it. A package that ships its first document adds one entry to
PACKAGED_DOCUMENTS in scripts/lib/compile.ts, which
scripts/tests/packaged-document.test.ts enforces.
deno compile embeds what --include names and nothing else, and reports no
omission: the binary compiles, runs, and fails at the moment a person asks it
for the asset that was left out. Three sites compile the xmd entrypoint —
deno task build, release.yml's matrix, and verify:clean's release phase —
and while each carried its own copy of the list they disagreed. The release's
copy named no components.md, so every published binary through v0.12.0 lists
every component and answers xmd syntax TempDir with a missing-asset error;
verify:clean's copy also omitted packages/cli/src/documents, so it proved a
binary narrower than the one a release publishes.
scripts/lib/compile.ts is therefore the one source. It owns the entrypoint,
the flags, and every embedded asset, in three lists that differ in how they are
maintained:
EMBEDDED_PACKAGES— a whole package the binary executes Markdown out of. Empty, and not because no Plugin ships.@executablemd/gitis bundled, but it is statically imported, so its module graph is already part of the program the binary contains and its documentation asset travels throughPACKAGED_DOCUMENTATIONbelow. An entry here is for a package the binary executes Markdown out of without importing; naming one nothing imports would embed bytes no code can reach.UNEMBEDDED_PACKAGES— the packages whose assets the binary deliberately does not carry, so the discovery sweep below does not demand them. The code-review package is the one: it is selected with--pluginand reads its own assets from wherever the operator installed it.PACKAGED_DOCUMENTS— each package'ssrc/documents/, embedded whole (§9).PACKAGED_DOCUMENTATION— each package'scomponents.md, named individually because the directories they sit in are package source, and embedding those whole would carry the TypeScript into the binary a second time.
compileArguments() builds the complete argv from them and refuses an unknown
--target before anything is spawned. scripts/compile.ts is the command a
task or a workflow invokes; it supplies only --output and an optional
--target, and refuses an argument it does not define, because a silently
dropped --target would compile the runner's own platform and upload it under
another platform's artifact name. verify:clean builds the same argv from the
module directly, since it spawns its phases inside a clone of HEAD.
The lists partition the assets rather than overlapping. A whole entry in
EMBEDDED_PACKAGES would already carry every src/documents/ directory and
every components.md nested inside it, so those are not added to the
individual lists as well: naming either individually would compile the same
bytes twice while reading as the coverage the whole-package entry already
provides.
scripts/tests/packaged-document.test.ts walks the repository for both
discoverable kinds and holds the lists to what it finds in both directions —
a missing entry ships a binary without its asset, a stale one embeds nothing
while reading as coverage, and a redundant one is rejected against the
whole-package entry that already covers it. UNEMBEDDED_PACKAGES hides a
package from that sweep, which is exactly the kind of claim that rots, so the
same test checks it: a CLI production module importing one — or the CLI package
depending on it — fails there, because the package would then be in the binary
with its assets missing rather than deliberately absent.
Metadata and documentation come from different places: a component's forms,
props and origin come from the registry in the module graph, and its long-form
prose comes from the packaged components.md. A binary that shipped the graph
and none of the assets therefore lists every component and prints every
component's metadata — which is why nothing short of asking for documentation
detects it.
scripts/smoke-documentation.ts asks, from a temporary directory that is not
the checkout and with a component search path of its own, so neither the working
directory nor --include can answer for the product's own documentation:
xmd syntax TempDirrenders the registry metadata and the long-form prose;xmd syntax TemdDirreaches the ordinary unknown-name refusal with empty stdout — the half that makes the probe evidence, because a binary missing its assets fails both lookups the same way, so a passing first case alone would not distinguish the two;<Syntax names={["Elicit", "File"]} />renders both packaged descriptions.
It runs in two places against one script. ci.yml's smoke job runs it against
the dist/xmd the README's Build target produced, so a dropped include fails a
pull request. release.yml runs it against dist/${{ matrix.artifact }} on the
runnable x86_64-unknown-linux-gnu member — a cross-compiled binary cannot be
run by the job that produced it — after the compile and before the
attestation, so a build that cannot document its own components never becomes an
attested subject and never reaches the artifact set release downloads.