uv sync
uv run pytest -q # while iterating: name the file you touched
uv run pytest -n 8 -q # the whole suite, before pushing
node --experimental-vm-modules tests/preview_persistence.mjs.python-version pins CPython 3.12.14, so uv sync fetches that exact
interpreter instead of taking whichever 3.12 or later the machine happens to
have. It carries export-ignore, which keeps it out of the release: a checkout
and CI build and test what ships, while an install resolves against
requires-python and reuses an interpreter it already has. Moving a patch
digit here is no reason for everyone to download a second CPython, and uv
removes neither of them on its own.
-n 8 is pytest-xdist and belongs on the full run only. Each worker costs about
two seconds to start, which is more than a scoped run takes at all.
The page has no browser test. tests/preview_persistence.mjs runs its modules
against a stub DOM in Node, which is enough to cover what the page remembers,
what it sends and when it redraws. It links them as modules, each in its own
scope, so a module that reads a name it never imported fails here rather than
on the first click. That needs --experimental-vm-modules, which is why the
line above carries it.
Two static checks run before any of that, because what they catch is a page
that never loads, and a page that never loads has no behaviour to test. One is
the missing import above. The other is the reason start.js exists: the import
graph has cycles, so a module body runs while a module it imports may still be
evaluating, and a binding read there is in its dead zone. Wiring that crosses
modules goes in start.js, and each module exports a wire... function for it
to call. A handle a module owns itself cannot be in a dead zone, so those
listeners stay in the module.
Almost every test builds its own faces with tests/fontsmith.py, which
synthesizes a font from a list of codepoints. Three kinds of test cannot:
whole-page rendering, kerning read from a real GPOS table, and stem darkening,
which only the Adobe CFF driver applies.
Those read a font off your machine, and skip when there is none. Name one and they run:
| variable | wanted |
|---|---|
CROSSGLYPH_TEST_FONT |
a text face with Latin and Cyrillic |
CROSSGLYPH_TEST_ITALIC |
its italic |
CROSSGLYPH_TEST_OTF |
an OTF with a CFF outline, ligatures and a pnum feature |
A firmware checkout beside this one supplies NotoSans, which a few metrics
tests read directly, and NotoSansArabic, which the two per-glyph size cap tests
read for the one glyph large enough to reach it. The same checkout carries
lib/MiniBidi/minibidi.c, which one test parses to check our Arabic form table
against the firmware that reads it. Those three skip without it and say so.
The Arabic tests otherwise need no font at all: fontsmith.joining_font
synthesizes a face that joins through GSUB and carries none of the shaped
codepoints, which is the shape of font the synthesis exists for. That is
deliberate, so the path cannot pass by skipping on a machine with no Arabic
face.
Name one before believing a green run. A third of the suite is behind these, and the synthesized faces differ from a real one in ways tests can rest on without saying so: they are narrow, so words fit in columns a real face overflows.
git config core.hooksPath .githooksThat is one hook, and it checks the line endings described below.
tools/uv.cmd is a polyglot. Bash reads the top half; cmd.exe jumps past it
to the bottom half. The two halves need different line endings, so the file
carries both:
:<<"::CMDLITERAL" CRLF, the header cmd.exe parses
@ECHO OFF
GOTO :CMDSCRIPT
::CMDLITERAL
# bash body LF, which bash runs and cmd.exe skips
exec "$root/tool-wrapper.sh" "$@"
:CMDSCRIPT CRLF, the batch body
call "%~dp0tool-wrapper.cmd"
Making either half uniform breaks one of the two interpreters. All LF misparses
under a double byte code page on Chinese, Japanese and Korean Windows, where
cmd.exe reports errors like 'etlocal' is not recognized. It only shows up
above a certain file size, where a buffer boundary lands inside a word. All
CRLF puts carriage returns in the bash body, where they become part of the
tokens.
.gitattributes carries *.cmd -text, so git normalizes nothing on checkout
under any core.autocrlf. Most editors rewrite a whole file to one ending and
show no diff for it, which is why there is a checker:
sh tools/check-line-endings.shThe pre-commit hook runs it when a .cmd file is staged. There is no repair
mode. If a wrapper is mangled, git checkout -- tools/uv.cmd puts the bytes
back.
Two more rules for that file. Copy an existing wrapper rather than writing one
from scratch, and never run dos2unix or unix2dos on one.
uv run tools/bump-uv.py # to uv's latest release
uv run tools/bump-uv.py 0.12.4 # to a named one
uv run tools/bump-uv.py --commit # and commit the resultThe version and its checksums appear in the wrapper twice, once in each half, and a hash copied wrong lands on the one platform nobody bumping it is running. The script reads uv's newest tag off the redirect from its releases page, takes the SHA-256 that astral publishes beside each archive, and swaps those fourteen strings and nothing else. Replacing fixed substrings is what keeps the line endings exact, and it refuses to write a file whose two halves have come to say different things.
Then it runs the wrapper once, which downloads one archive and checks its hash
for real. A published checksum cannot do that: it says what the archive should
hash to, not that the file at that address is the one it describes. --verify
does the same across all six platforms, about 120 MB, and is worth the wait
before a release.
--commit writes chore(tools): bump uv to <version> for that path alone, and
refuses to start if the wrapper already has uncommitted changes, so a bump
cannot carry an unrelated edit along with it.
Bump the version in pyproject.toml, commit it, then tag and push. X.Y.Z in
this command stands for the version just set:
git tag vX.Y.Z && git push origin vX.Y.ZThe workflow does the rest: it checks that the tag and the version agree,
builds and publishes the zip and container image, and puts the manifest on
Pages. A tag that disagrees with pyproject.toml fails the run rather than
shipping a release that misdescribes itself.
The ZIP carries compose.yaml, compose.build.yaml and both Docker launchers
at its root. The first Compose file selects the matching published image; the
second builds the source under the matching versions/ directory as
crossglyph:local. Dockerfile and .dockerignore live inside that versioned
build context. The release therefore supports the same image and local-build
commands as a checkout.
A second workflow, test.yml, runs the suite and the page against Ubuntu and
Windows on every push and pull request. The release calls it before it builds
anything, so a tag cannot publish what the suite has not passed. That is a
workflow_call rather than a needs: on the job over there, because needs
only names jobs inside one workflow.
One push does not get it. A release pushes master and then its tag seconds
apart, and the tag names the commit master already points at, so the suite
would cover the same tree twice. Both jobs skip a branch push whose head
commit is a chore(release): subject. The tag's run is the gate, and it
still runs everything.
It drives the pinned uv through tools/uv.cmd, so each run verifies that
checksum on both platforms and tests on the interpreter .python-version
names. Two steps guard what the suite cannot say for itself: the wrappers keep
their line endings, and the faces the rendering tests need are present, since
a third of the suite skips without them and says nothing while doing it.
A first push made by this public repository's GITHUB_TOKEN creates a public
package. Seed the package once from a maintainer account instead:
workspace="$(mktemp -d)"
trap 'rm -rf "$workspace"' EXIT
mkdir "$workspace/context" "$workspace/docker"
printf 'private CrossGlyph candidate package\n' >"$workspace/context/marker"
printf 'FROM scratch\nCOPY marker /crossglyph-private-candidate\n' \
>"$workspace/context/Dockerfile"
gh auth token |
DOCKER_CONFIG="$workspace/docker" docker login ghcr.io \
--username CrazyCoder --password-stdin
DOCKER_CONFIG="$workspace/docker" docker buildx build \
--platform linux/amd64 --push --provenance=false --sbom=false \
--tag ghcr.io/crazycoder/crossglyph-testing:bootstrap "$workspace/context"The gh token needs write:packages. The temporary Docker configuration
keeps this login separate from the maintainer's normal Docker credentials.
After the push, open Profile > Packages > crossglyph-testing > Package
settings > Manage Actions access, add the crossglyph repository with the
Write role, and leave the package private.
Run Actions > container candidate > Run workflow to test the registry path
without making a release. The workflow runs test.yml, requires authenticated
access to the private bootstrap, and proves that an anonymous manifest request
cannot read it before pushing the current commit with a candidate-<commit>
tag and the source-repository label.
The workflow repeats the anonymous check after that link, checks the remote
AMD64 and ARM64 manifest while authenticated, pulls the AMD64 image back from
GHCR, builds a real .cpfont, and waits for its preview to answer. If either
anonymous check can read an image, delete the package. GHCR does not allow a
public package to become private again. Old private candidate versions can be
deleted from Profile > Packages > crossglyph-testing > Package settings
when they are no longer needed.
This workflow creates no Git tag or GitHub release, and it does not deploy the Pages manifest. Existing installs therefore have no candidate version to offer as an update.
After the first workflow publishes the release image, open **Profile > Packages
crossglyph > Package settings** and make sure its visibility is Public. If it is private, use Change visibility > Public. This is a one-time setting. A private package asks Docker users to sign in instead of allowing the anonymous pull that
compose.yamluses.
The image's source label links the package to this repository before the first
push. The package then inherits repository access, and the release workflow can
publish later tags with its packages: write permission.
Two things about Pages have to be true before the first tag, and neither says so when it is not.
Pages must be turned on with GitHub Actions as the source. The manifest is deployed after the release is published, so without it a tag creates the release and then fails, leaving something no install can discover and a rerun that stops at "release already exists".
And the github-pages environment that turning it on creates allows
deployments from the default branch only, while the release runs from a tag.
The job is then rejected before its first step: two seconds, no steps, no log
to say why. Once per repository:
gh api repos/<owner>/<repo>/environments/github-pages/deployment-branch-policies \
-X POST -f name='v*' -f type=tagTo build one locally without releasing it:
uv run tools/make-release.pyIt packs dist/crossglyph-<version>.zip from HEAD, and refuses to run against
a dirty working tree, since the archive comes from the commit rather than from
your files. Beside the zip it writes dist/latest.json, hashed from that very
file, which is what installs read to learn a newer release exists.
git archive does the reading; the tree is then repacked so the launcher, the
workspace and update.conf sit at the root and the code goes under
versions/<version>/. Members are copied as bytes rather than through the
filesystem, which is what keeps the wrappers exact.
The workspace files are the one thing that lands twice: at the root, which is the copy the user edits, and inside the version, which is the record of how they shipped. An update compares against that second copy to tell a file somebody edited from one that changed between releases, and without it every install would look edited.
The launcher lands twice for the same reason: the root copy is the one that runs, and the one inside the version is what an update stages beside it. A release can therefore fix the launcher of an install already out there, which is the whole reason it is in the version at all.
It cannot be replaced during the run that installs it. Both cmd.exe and a
POSIX shell read a script as they execute it and resume at the byte offset
they had reached, so a file that changed length underneath them is read from
the middle of a word. Measured on both rather than assumed: on Windows a
mid-run replacement, by write or by rename, makes cmd execute fragments like
'ause'; on Linux dash re-ran two lines and then failed. So the updater
writes crossglyph.cmd.staged and never touches the live file.
Two rules follow, and both are checked by tests/test_shim.py:
- The apply must be one line ending in
exit /B. Anything after it, even a)closing a block cmd has to skip, is a read from the replaced file. The cost is that this one run reports exit 0 whatever the tool returned;crossglyph.shusesexecand has no such trouble. - An old launcher must be able to start a new version. It is one launch
behind for exactly one launch, so keep it thin: read
current, run the version it names. Anything that might need fixing belongs in the versioned tree, where a release replaces it outright.
The script then reads the archive back and checks that everything a release
needs is in it, that no checkout furniture or state file came along, that the
executable files still are, that every entry carries a Unix mode a POSIX
unzip will apply, and that tools/uv.cmd still has its mixed line endings.
A release that lost one of those unpacks cleanly, runs nowhere, and says
nothing about why. The mode check is there because losing it is silent on
Windows and total on Linux: an entry that keeps 0755 but does not say Unix
extracts unrunnable.
.gitattributes, .gitignore, .githooks/ and .github/ carry
export-ignore, so they stay out of the archive. They mean nothing to
somebody unpacking a zip.
src/crossglyph/render/render.wasm is committed, because a release has no
toolchain to build one with. Rebuild it after pulling the firmware, and after
editing anything under src/render/.
The engine has a firmware checkout of its own, crosspoint-reader-engine
beside this repository, tracking develop. Nothing works in it. A checkout
you build firmware and run the emulator from moves between branches for
reasons that have nothing to do with the preview, and every one of those moves
would otherwise change what the core is built from and make the staleness
warning fire.
uv run tools/update-engine.py # clone it, or fetch and fast-forward
uv run tools/update-engine.py --dry-run # ask, change nothing
uv run tools/update-engine.py --ref v1.2 # a one-off, detachedIt reports which commits since the stamp touch anything the build compiles, sources and include directories both, so "has the renderer moved" is one command rather than a reading of the firmware log. It never builds: that needs emsdk, and on Windows a shell this is not.
The build resolves the firmware in one order, and render/stamp.py resolves
the same one so that what the module is built from and what it is judged
against cannot drift:
$CROSSGLYPH_FIRMWARE -> ../crosspoint-reader-engine -> ../crosspoint-reader
The last of those keeps a single-checkout setup working, which is what a
contributor and CI have. The first is how a build from a fork of the firmware
works, without anything here knowing that forks exist. $FW overrides the lot
for the build alone.
It needs emsdk
beside this repository, or $EMSDK naming it somewhere else.
bash src/render/build.shOn Windows the script wants a MSYS2 bash:
c:/tools/msys64/usr/bin/env.exe MSYSTEM=MINGW64 \
c:/tools/msys64/usr/bin/bash.exe -lc 'bash <repo>/src/render/build.sh'Two things there are easy to get wrong and hard to diagnose. The C++ goes
through em++ and the C through emcc, because each rejects the other's
sources. And a stub HAL must not have a data member: HalDisplay keeps its
framebuffer in a function local static because as a member of an inline
variable it came out null in one translation unit and valid in another, so
every drawn pixel vanished in silence.
The stubs under src/render/hal/ stand in for the firmware's own HAL, so
their signatures have to track it. When the firmware changes one, the build
says so as a compile error naming both, which is the good case: an argument
added to displayGrayBuffer upstream stops the build rather than the page.
The build writes a stamp beside the module: the commit, the repository it came from and the branch it was on. A checkout whose firmware has moved past that commit gets a warning, once per run, and the preview draws with the older renderer until you rebuild. A release has no firmware checkout, so nothing is compared and nothing is said.
The repository name is in the stamp because the directory it was built from is
not the answer: an engine checkout is named for its job, and a second firmware
would be a second name. crossglyph --version reports what the stamp says.
This covers the README, docs/, --help text, error messages, the strings in
the preview, the release notes and the comments in the code.
Write for somebody who wants to build a font and is not a programmer. Plain
English, short common words, no em dashes and no -- standing in for one,
straight quotes, no emoji, sentence case in headings.
Font and rasterizing terms are the point of this tool and strange to most
readers. Gloss each one the first time a page uses it. "Stem darkening thickens
the strokes, which helps on a screen where thin letters look washed out" tells
the reader something; "stem darkening applies a stem darkening factor" does
not. Name settings the way the reader's own surface names them: the label for
anything about the preview, the config key for anything about build.
Leave out numbers that go stale. Version numbers, glyph counts, download sizes and benchmark timings are all wrong a few commits later and nobody notices. Give the shape instead, and keep a number when something fixed holds it in place, such as the four greys the screen draws.
Comments explain why, not what. Both comments and pages describe what the code does now. Git holds the rest, and a comment retelling it is wrong by the next change. A firmware behaviour a value works around is worth a citation, and those citations are why several comments here are long.