Course definitions and expected file hashes live in courses/*.nix. Nix builds
one fixed-output derivation per source and audio variant, then assembles the
JSON metadata and content-addressed release. Media is not stored in Git and is
not licensed under the repository's code license.
The supported builder is x86_64 Linux. flake.lock pins the toolchain;
nix/ffmpeg.nix pins separate FFmpeg 8.0 and 8.0.3 sources and builds against
musl. See the FFmpeg notes for why the libc and versions matter.
Import the sources using the hashes recorded in the course modules:
nix run .#import-source -- data/core
# Optional: reuse an existing directory of computed files.
nix run .#import-computed -- /path/to/cas
nix build .#cas --keep-going
# Imported audio is reused; this checks packaging, not reproducibility.The importer verifies every source against its declared SHA-256, adds the file
to the Nix store, and roots it under .gc-roots/source/. It never modifies the
input directory. Course lists and integrity sidecars in that directory are no
longer inputs; the Nix course modules replace them.
result/ contains all-courses.json and objects at <first-two-hash-chars>/<rest>.
It uses symlinks to store objects; dereference symlinks when copying it out:
mkdir -p data/courses
cp -RL result/. data/courses/The copied directory is self-contained. Upload hashed objects append-only, then
publish the new all-courses.json deliberately. Do not delete old server objects:
existing installations may still reference them. The build does not publish.
To build just one course:
nix build .#course-arabic --keep-going
# Equivalent: ./build-for-language.sh arabic --keep-goingCompare the exact local release with freshly fetched published metadata:
nix run .#release-cas -- result
# Also list every added media reference (changed/removed ones are always listed):
nix run .#release-cas -- result --detailsThis fetches https://downloads.languagetransfer.org/all-courses.json with a
cache-busting query and no-cache headers, walks its course JSON pointers, and
verifies those JSON hashes. It also checks the local release's object hashes
and pointer sizes. It does not build, import, or download published audio.
The report shows the root and course JSON changes, including old/new course JSON hashes and semantic changes such as titles, durations, and ordering. It matches media by course ID, lesson ID, and variant. New variants are additions; changes to an existing media pointer receive a prominent warning. Old objects are never invalidated or removed: these are changes to what clients following the new root will request. Reported bytes count unique media newly referenced relative to the published tree, not predicted device downloads or an inventory of everything already uploaded to the bucket. IDs are positional, so inserting lessons can appear as many changed references.
To review again and then offer an interactive upload:
nix run .#release-cas -- result --upload
# Defaults to the destination from the old upload instructions:
# --remote lt-r2:lt-app-casPublishing requires a terminal and typing publish lt-r2:lt-app-cas at the
prompt. There is no --yes or noninteractive mode. The existing rclone config
supplies credentials; the command includes rclone through Nix. For another
publication, use --published-root URL and --remote REMOTE:PATH explicitly.
The destination's current root must match the freshly fetched public root.
The uploader snapshots the reviewed root bytes and resolves object symlinks,
then runs rclone copy --copy-links --immutable --checksum for hashed objects.
It never syncs or deletes remote files. A conflicting existing object causes a
failure rather than an overwrite. Only after that succeeds does it run
rclone copyto for all-courses.json, and then check the destination root.
If copying fails, added objects may remain, but the root is not advanced.
The root is rechecked before uploading and before publishing to detect another release arriving during the review. This is not an atomic compare-and-swap: use one publisher at a time. If the public CDN and bucket roots disagree, wait for propagation and compare again. No upload is attempted until confirmation.
Resolution is: matching local store object, configured Nix binary cache, then our builder. The builder tries the known hash on the CAS and, for audio, runs the pinned recipe if fetching fails. Nix checks the final bytes against the expected hash. Sources have no generation step: import them locally, or make them available on the CAS. A download that succeeds with the wrong bytes fails Nix's hash check.
A builder's dependencies are prepared before it runs, so source files must be available even if the builder eventually downloads the finished audio.
nix build .#hq --keep-going # All HQ outputs, separate derivations
nix build .#audio --keep-going # All three variants
nix build .#retained -o .gc-roots/contentThe retained target keeps both sources and the release alive. A normal result
link keeps its release and referenced audio, but source files need the importer
roots or the retained target. Keep those symlinks to preserve the objects across
Nix garbage collection.
Existing audio directories can be imported without re-encoding:
nix run .#import-computed -- /path/to/casThis verifies the declared audio hashes and roots the imported files under
.gc-roots/computed/. It tries known paths and hash filenames (including the
release's two-level layout), then scans remaining files by their actual hashes.
It does not interpret Dagger cache keys. Verified matches are imported and rooted even when other files are missing or
invalid. Missing files are listed at the end and the command exits with status 1;
successful imports remain retained. Use --dry-run to verify without importing, and --course ID
or --track FILENAME to select a subset (also supported by import-source).
No separate cache manifest is needed; the import plan comes from the course
modules.
Normal builds obtain pinned bytes from the Nix store, a binary cache, the CAS, or the encoding recipe. Nix checks built/downloaded outputs against their pins; this is always part of the build, not a separate verification command.
The separate reproducibility test forces the recipes to run even when outputs
are already cached. It disables CAS fetching for those recipes and uses
--rebuild on individual outputs, testing HQ first and then LQ/MOV. A mismatch
fails the test; it never changes pins. Each pass runs sequential batches of at
most 64 outputs, with Nix handling parallelism inside each batch. Progress labels
show the pass and batch number. A failed batch stops the test before later
batches or dependent passes.
nix run .#test-audio-reproducibility
# Limit the test to a course or track:
nix run .#test-audio-reproducibility -- --course arabic --track arabic004.mp3
# Print the commands without building anything:
nix run .#test-audio-reproducibility -- --course arabic --dry-runUse --stage hq or --stage derived to run just one pass. This test is optional
and separate from producing a release; normal builds retain all cache reuse.
Add a filename to the course's tracks list and a named declaration to pins:
"arabic039.mp3" = {
source = lib.fakeHash;
computed = {
hq = lib.fakeHash;
lq = lib.fakeHash;
hqMov = lib.fakeHash;
};
};For new course modules, also add them to courses/default.nix and make the
files visible to Git (git add -N courses/new-course.nix) so the flake sees them.
Keep source files outside Git, under CORE/courses/COURSE/tracks/FILENAME.
Record the source hash, review it, then import its bytes:
nix run .#record-hashes -- --core /path/to/core --course arabic --track arabic039.mp3
# Repeat with --write to accept the proposed source hash.
nix run .#import-source -- /path/to/core --course arabic --track arabic039.mp3Build HQ first. A lib.fakeHash deliberately produces a hash-mismatch error
containing the actual hash. Save the log and use the helper to preview changes:
set -o pipefail
nix build .#hq --keep-going 2>&1 | tee /tmp/lt-hq.log
nix run .#accept-hashes -- /tmp/lt-hq.log
nix run .#accept-hashes -- /tmp/lt-hq.log --write
nix build .#audio --keep-going 2>&1 | tee /tmp/lt-derived.log
nix run .#accept-hashes -- /tmp/lt-derived.log
nix run .#accept-hashes -- /tmp/lt-derived.log --write
git diff -- courses
nix build .#cas --keep-goingThe helper only accepts recognizable computed-output mismatches, reports old and proposed hashes, and rejects conflicting results. It never runs a build. Review the log for other failures too; accepting reported hashes does not mean all selected tracks succeeded. Logs must come from the current recipes: the helper cannot determine whether a saved log is stale.
A placeholder establishes a new expectation. Changing an existing computed
hash changes the bytes clients cache. For recipe changes, use test-audio-reproducibility to
explicitly rebuild existing outputs and inspect failures. To discover replacement
hashes, set only the intended fields to lib.fakeHash, then use the two-pass
workflow above. Review the course diff to see exactly which variants change;
ordinary builds never rewrite pins. HQ changes can affect both derived variants.
Keep derivation names stable when accepting hashes; Nix may reuse completed
hash-mismatch outputs, but they are not GC-rooted by a failed build. A successful
build/import and its retained roots establish retention.
courses/default.nix controls course order. Each imported module declares its
ID, default recipe, ordered tracks, and per-file hashes:
{ lib, series, ... }:
{
id = "example";
recipe = "mp3_8_0";
tracks = series "example" "mp3" 1 3;
pins = {
"example001.mp3" = {
source = lib.fakeHash;
computed = { hq = lib.fakeHash; lq = lib.fakeHash; hqMov = lib.fakeHash; };
};
# Entries for 002 and 003 as well.
};
}series has inclusive bounds and three-digit padding. Lists concatenate with
++, so filename exceptions are ordinary strings between ranges. The helpers
titled, restartAt, and withRecipe accept either a filename or another
helper's result:
[
(titled "Track 0" "ingles_completo000.wav")
(restartAt 1 "ingles_completo001.wav")
] ++ series "ingles_completo" "wav" 2 70
# Override the course recipe for a particular track:
(withRecipe "wav_8_0_3" "replacement.wav")The title counter starts at 1, resets before that track's title, and increments
after every track including titled tracks. IDs remain <courseId><position+1>;
changing a displayed title or counter does not change its positional ID.
Recipes are defined in nix/recipes.nix: MP3 HQ is stream-copied with FFmpeg 8.0;
WAV HQ uses native AAC -q:a 1.69 with 8.0.3. LQ uses the same encoder as its
HQ recipe, with AAC 64 kbps mono. MOV always remuxes HQ with 8.0.3, without
re-encoding. Hashes remain authoritative regardless of the selected recipe.
all-courses.json contains { buildVersion: 2, casBaseURL, courses }, where each
course is { id, meta, lessons }. Its course metadata contains
{ buildVersion: 2, lessons }, with lesson ID, title, duration in seconds, and
lq, hq, and hq-mov variant pointers. File pointers are
{ _type: "file", object, filesize, mimeType }.
MP4 pointers use video/mp4, MOV uses video/quicktime, JSON uses
application/json. Audio hashes are verified by Nix; metadata hashes derive
from the serialized JSON. The app uses hq-mov for iOS high quality and hq
for Android. Build version 2 remains unchanged.
The browser/ directory contains a viewer for the resulting CAS.