Skip to content

fix(dart): keep the build directory out of the vodozemac bindings - #62

Open
nikzen wants to merge 13 commits into
mainfrom
fix/vodozemac-darwin-build-paths
Open

fix(dart): keep the build directory out of the vodozemac bindings#62
nikzen wants to merge 13 commits into
mainfrom
fix/vodozemac-darwin-build-paths

Conversation

@nikzen

@nikzen nikzen commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

nix build --rebuild shows that two builds of famedly-vodozemac on a Mac are
never the same bytes. Nix builds in /nix/var/nix/builds/nix-<pid>-<random>,
and darwin has no sandbox to remap that to a constant, so the path reaches the
artefacts: 104 occurrences in the shared library, 212 in the static one, the
symbol suffixes LLVM hashes from a module's path, and LC_ID_DYLIB, which ld64
fills with the full output path when no install name is given.

Remapping the prefix leaves the static library reproducible down to the
byte
and takes the shared one from ~1500 differing bytes to 48: the Mach-O
UUID and the ad-hoc signature over it. -Wl,-no_uuid would remove those at the
cost of ever symbolicating a crash, which is the worse trade.

The install name is worth fixing either way. The library announced its own
location as a build directory that stopped existing when the build finished.
Nothing reads it, since flutter_rust_bridge opens the file by the path it is
handed, but anything that linked against it would have kept that reference.

Inert on Linux: the hook is darwin-only, and the sandbox already mounts the
build directory at a constant /build. web.nix is a different artefact by a
different route and would need its own measurement.

Stacked on #56, where native.nix lives — the file this used to patch does not
survive that split. The derivation does: with this applied, both branches
produce the same store path.

Found while measuring what was worth putting in the shared binary cache (#59),
which it does not block.

Test plan

  • nix flake check --all-systems, prek --all-files --stage pre-push
  • nix build --rebuild on aarch64-darwin: the .a is byte-identical,
    the .dylib down to 48 bytes, no build directory paths left
  • otool -l shows the install name under $out/lib
  • a Dart project using the bindings still loads them

Made with Cursor


The stack

main ─┬─ #63  make room on the runner
      ├─ #59  substitute from the shared binary cache
      └─ #56  sign, describe and scan what we publish
            └─ #62  keep the build directory out of vodozemac  ←
                  └─ #61  tell a web build which build it is
                        └─ #64  let a project choose its vodozemac

#63 and #59 stand outside the chain and can land in any order. #59 and #56
both touch the vodozemac module, so whichever goes second wants a rebase.

@nikzen
nikzen force-pushed the fix/vodozemac-darwin-build-paths branch from 1fcd558 to 49ca2ff Compare August 3, 2026 16:33
@nikzen
nikzen changed the base branch from main to feat/supply-chain August 3, 2026 16:33
nikzen and others added 9 commits August 3, 2026 22:19
A registry entry and a scanner both ask an image where it is from, and
ours had no answer: the annotations OCI reserves for that were absent, so
the only handle on a published image was its tag — and a tag moves.

The build takes the repository, the commit and the ref name as optional
arguments and turns them into annotations. Optional, because a build by
hand has no commit to name, and refusing to build then would be worse
than an image that says less.

Deliberately without `org.opencontainers.image.created`: a timestamp
would make two builds of one commit differ, and the commit named here has
a date already.

Co-authored-by: Cursor <cursoragent@cursor.com>
The server sent none of them, because it takes headers only from a file
and had none. It has one now, generated from the same option the smoke
test reads, so the image and the test that fetches from it cannot
disagree about what it sends.

What is in it by default says nothing about any particular application:
no sniffing, no framing, a referrer only within one origin, no camera,
microphone, location, payment or usb, and HSTS in case the ingress ever
forgets it. `Content-Security-Policy` is not among them — a policy that
fits one application forbids another one's inline bootstrap, so it stays
with the project.

Cross-origin isolation is an option and off by default: it buys a Flutter
build the threaded renderer it otherwise ships without using, and costs
every cross-origin resource that does not opt in.

The server also answers `/health` now, which is what Kubernetes asks
before it sends anyone here, and the smoke test asks the same.

Co-authored-by: Cursor <cursoragent@cursor.com>
Three things every one of these workflows was missing.

Each workflow now starts its jobs at `contents: read`, so a job added
later reads the repository and nothing else until it says otherwise. The
two that publish — Pages and the review app cleanup — already asked for
what they need, and now stand out for it.

Each job carries a timeout. Not to hurry a build that works: a runner
waiting for something that will never arrive holds a queue for six hours,
and the only workflow this replaced that bounded anything bounded it at
five minutes.

And the checkout no longer leaves the token in `.git/config`, where every
later step could read it — including code generators and package scripts.
Nothing of ours talks to the remote after the checkout; the steps that
use the API take the token as their own environment variable.

Co-authored-by: Cursor <cursoragent@cursor.com>
Nobody could answer what is inside one of our images without pulling it
apart, which is the question every advisory starts with.

An SPDX document is now written for each archive before it is pushed, from
the archive rather than from the recipe, so it describes what is about to
be published and not what was meant to be. A second one is written from
the lockfile: a compiled bundle carries no trace of the packages it came
from, so the image's own contents say nothing about the application's
dependencies — and those are the ones advisories are written about.

Both are kept as an artefact of the run. Tying them to the image in the
registry follows.

Co-authored-by: Cursor <cursoragent@cursor.com>
A pull by tag got whatever the registry had under that tag, and there was
no way to tell whether it came from here. Now every digest we push is
signed, and the SPDX documents from the previous commit are attached to
the digests they describe, so what an image holds travels with it instead
of expiring with the run that built it.

Keyless, so there is no key of ours to hold, leak or rotate: the signature
is bound to this workflow's identity and GitHub vouches for that over
OIDC, which is why the job now asks for `id-token`. The price is a public
record in the transparency log naming the image and its digest.

The manifest list is signed too, and it is the one that matters: it is
what a pull by tag resolves to, so it is what a policy will check.

Co-authored-by: Cursor <cursoragent@cursor.com>
Nothing held our images or their dependencies against an advisory
database, so a package with a known hole travelled to production and
nobody was told.

The scanner reads the documents from the previous commits rather than
digging through the archives again, which means what it reports is about
exactly the list of packages that ships. The report lands in the run's
summary, where it is read without opening a log.

It reports and does not block, and `ci.advisories.failOn` is what turns it
into a gate. Off to begin with on purpose: a threshold set before anyone
has seen a report either stops every release on the day it lands or
teaches everyone to scroll past it.

Co-authored-by: Cursor <cursoragent@cursor.com>
A tag says a version exists and nothing more. Whoever is looking for what
changed has to find the changelog in the tree and read the right section
of it, and anyone watching the repository for releases sees nothing at all.

The workflow takes that section — whichever heading level it is written
at — and publishes it as the release for the tag. A version with a suffix
is marked as a prerelease, so a release candidate does not present itself
as the current version.

When the changelog has nothing to say about the version, GitHub's summary
of the commits stands in with a warning rather than failing the run: by
the time the tag is pushed, the file can no longer be fixed for it.

Co-authored-by: Cursor <cursoragent@cursor.com>
A Flutter web application needs the WebAssembly module and its glue, which
the native library is not a variant of. Both are cut from one pinned source
now, so the Dart package can never talk to bindings from another revision.

Collects everything vodozemac under nix/dart/vodozemac/ on the way.

Signed-off-by: Niklas Zender <n.zender@famedly.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A Flutter project depends on flutter_vodozemac, not on the plain package, and
the hook then found no constraint at all and failed every commit. Both names
are cut from the tag the bindings are built from.

Signed-off-by: Niklas Zender <n.zender@famedly.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
nikzen and others added 3 commits August 4, 2026 09:08
The vodozemac module and LiveKit's encryption worker are compiled artefacts of
dependencies that a web build expects in web/, and a repository that carried
them would carry copies that silently age.

The worker is compiled from the package the project already resolved, so it
follows pubspec.lock instead of a second clone pinned by hand. One script does
both, and the devshell carries it: an asset only CI can produce is one a
developer cannot reproduce when the site misbehaves in the browser.

Signed-off-by: Niklas Zender <n.zender@famedly.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
`./pkg/` is the default `wasmPath` of both the plugin and the plain package, so
an application that gets the module from us now needs to pass nothing at all.
An application that passes a path of its own has to drop it.

Signed-off-by: Niklas Zender <n.zender@famedly.com>
The eight-core larger runner was the only way to build for arm64 when these
workflows were written: GitHub's standard arm64 runners were public
repositories only. They reached private ones in January, so the choice is now
one of price against wait.

Measured, both runners on the same commit, twice each. The larger one has eight
cores, 31 GB and 393 GB of disk; the standard one four, 15 GB and 109 GB — more
than the documentation promises, and far more than a three gigabyte Flutter
closure needs. Fetching and unpacking the Dart SDK takes the same time on both,
compiling the vodozemac bindings 44 against 59 seconds, and the job as a whole
137 and 122 against 157 and 148.

That is 1.16 times faster for 2.8 times the minute, since a larger runner also
draws nothing from the included minutes. It would have to reach 2.8 to break
even. Half of a build of ours is spent moving bytes rather than compiling them,
and no number of cores shortens that.

The option stays for the project whose arm64 build really is compilation from
end to end, and `arm64Release` still follows it for one that wants to spend
more on a release than on a nightly.

Signed-off-by: Niklas Zender <n.zender@famedly.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Nix builds in `/nix/var/nix/builds/nix-<pid>-<random>`, and on darwin, where no
sandbox remaps that to a constant, the name reached the artefacts: a hundred
and four times in the shared library, two hundred and twelve in the static one,
plus the hashes LLVM derives from a module's path and the install name the
linker writes. Two builds were never the same bytes, so nobody could rebuild
the library and see that it matched what was shipped — which is most of what
pinning a dependency by version is for.

Rewriting the prefix leaves the static library reproducible down to the byte.
The shared one keeps a UUID and an ad-hoc signature the linker derives from the
rest; dropping the UUID would buy the last forty-eight bytes at the cost of
symbolicating a crash, which is the worse trade.

The install name is worth fixing whether or not any of that matters. Until now
the library announced its own location as a directory that stopped existing
when the build finished. Nothing reads it, because `flutter_rust_bridge` opens
the file by the path it is handed, but anything that linked against it would
have kept a reference to somewhere that was never there.

Signed-off-by: Niklas Zender <n.zender@famedly.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@nikzen
nikzen force-pushed the fix/vodozemac-darwin-build-paths branch from 49ca2ff to d7355b0 Compare August 4, 2026 07:29
Base automatically changed from feat/supply-chain to main August 5, 2026 03:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants