Terrain Viewer

GitHub releases, what each one holds

The GitHub releases of Iconem/terrain-viewer are a file store as much as a version list - desktop installers, the docs screenshots, slide deck PDFs, demo screencasts, UX flow captures and dataset indexes each have their own tag. What each holds, who writes it, who reads it, and how to add or retake a file

The repository keeps code and text. Anything heavy or regenerated goes to a GitHub release of Iconem/terrain-viewer instead, because a file committed once weighs on every clone for good. So the releases page is a file store with six kinds of content, only one of which is a software release in the usual sense.

The releases

TagHoldsSizeWritten byRead by
desktop-latestThe desktop app, full build with the docs inside: installers, portable zips and the update feed for Windows, macOS and Linux2.4 GB, 13 filesThe desktop workflow, replaced on every runUsers, the app's updater, the footer link
desktop-latest-lightThe light build, docs opening on the website330 MB, 12 filesSame workflowUsers, the light build's updater
desktop-v<version>The Windows installers of one version, never replaced690 MB, 3 filesSame workflow, once per versionwinget, the Desktop App download link
dataset-indexesBuilt VRT indexes too large to commit (Scotland LiDAR) and the coverage footprints (GeoJSON)23 MB, 10 filespnpm datasets:push <file>pnpm app and pnpm build, into public/vrt/ and public/coverage/; see Dataset indexes
docs-mediaThe docs screenshots, one tar archive per version181 MB an archive, 151 picturespnpm docs:media:pushpnpm docs:media, run before the app and docs dev servers, the docs build and the slide builds
slides-2026-10-05The five slide decks as PDF, a dated snapshot89 MB, 5 filesBy hand, from pnpm slides:pdfThe Slide Decks page
demo-takesThe demo screencasts, MP4184 MB, 4 filesBy hand, after a recordingThe Demo Videos page, which plays them from the release
ux-flowsThe UX flows captures, WebP and two JSON files7 MB, 32 filesThe UX flows capture workflow, replaced on every runThe Pages deploy, into dist/ux-flows/captures/

Three patterns are at work. A rolling tag has a bare name (ux-flows, demo-takes, docs-media, dataset-indexes) and its files are replaced or added to, so a link to it never goes stale and nothing that reads it needs editing. A dated tag (slides-<date>) is a snapshot never touched again. The desktop tags are the exception: desktop-latest and desktop-latest-light are rolling but keep their suffix, because the update feed address is inside every installed app, and desktop-v<version> is the permanent copy winget points at. A content-addressed store (docs-media) keeps every archive under its hash, and a lock file in the repository names the one a commit uses.

A release asset sends no CORS header, so a page cannot fetch one directly. Whatever the site itself has to read (the VRT indexes, the UX flows captures, the screenshots) is fetched into the build and served from the site's own origin. A video tag is not subject to CORS, which is why the demo videos play straight from the release.

How the releases page is ordered

GitHub lists releases by the date of their tag, newest first, and offers no folders, no pinning and no way to hide one short of deleting it or turning it into a draft. A title can be changed freely; the order cannot. What does help: every title starts with its kind (Desktop:, Data:, Media:), set by the workflows and scripts that create the releases, so the list reads by family; only the current desktop-v<version> is kept, older ones are deleted once no winget manifest points at them; the Latest badge is set explicitly and belongs to desktop-latest (the desktop workflow sets it on every run, so create any other release with --latest=false), and everything that is not software is marked pre-release, which greys it in the list and keeps it out of the repository's sidebar.

Docs screenshots

docs/public/screenshots is not tracked. docs/media.lock names the archive a commit uses (screenshots-<sha256 prefix>.tar, with its full hash, size and file count), and scripts/docs-media.mjs brings the folder in line with it:

pnpm docs:media        # download and unpack when the folder is not at the lock's version
pnpm docs:media:push   # pack the folder, upload it, rewrite docs/media.lock
node scripts/docs-media.mjs status
  • A fresh clone, another computer, CI: nothing to do. pnpm app, pnpm docs, the docs build and the slide scripts pull first. The download is public, no login needed. A clone that predates the move sees the pictures deleted by its next git pull and gets them back the first time one of those commands runs.
  • After retaking or adding a screenshot: pnpm docs:media:push, then commit docs/media.lock with the page that uses the picture. The push needs the gh CLI signed in with write access to Iconem/terrain-viewer. Until the lock is committed and pushed, every other machine and the deployed site keep the old pictures.
  • Do not leave a retake unpushed across a git pull: when the lock changes under a folder, the pull replaces the folder with the lock's archive, and a local picture that was never pushed is gone.
  • Old archives stay on the release, so an older commit still builds. The same release keeps posted-on-twitter.zip, the 13 pictures posted in 2025 that used to sit in the repository. The earlier versions of the pictures also remain in the git history: the move stops the growth, it does not shrink the past.

The archive is a tar, not a zip, for two reasons: the pictures are JPEG, PNG and MP4, already compressed, so a zip would save nothing; tar is on every machine the script runs on (Windows 10 and later, Git Bash, the CI runners) where unzip is not; and it unpacks with one command and no dependency. The name carries the archive's own hash, so two versions never collide.

Dataset files

The VRT indexes and the coverage footprints work like the screenshots, file by file: datasets.lock.json at the root lists each file with its folder under public/, its size and its hash, and scripts/dataset-assets.mjs fetches what is missing or behind the lock. pnpm app runs it, and so does pnpm build, strictly, which is how the website and the desktop bundles get the files.

pnpm datasets                                      # fetch what is missing or behind the lock
pnpm datasets:push public/coverage/esri-3d.geojson # upload a rebuilt file, rewrite the lock
node scripts/dataset-assets.mjs status

An entry marked "rolling": true in the lock is not pinned to its hash: the monthly coverage rebuild replaces those files on the release without a commit (keeping each month as <name>.<YYYY-MM>.geojson, with a running coverage-history.json), a build takes whatever is there, and pull --refresh updates a local copy. A file changed locally and not yet pushed is never overwritten by a pull; the script says so and keeps it. A build fails when the release holds a file other than the one the lock names, so push and the lock commit go together.

Adding a file to a release

# an index too large to commit: build it into public/vrt/, then
pnpm datasets:push public/vrt/<name>.vrt   # and commit datasets.lock.json
# a screencast
gh release upload demo-takes video/out/<take>.mp4 -R Iconem/terrain-viewer
# a new release that is not software
gh release create <tag> <files> -R Iconem/terrain-viewer --prerelease --latest=false --title "<Kind>: <what>"

The next deploy picks up anything in datasets.lock.json and on ux-flows; a file the site must serve from another tag needs a download step in .github/workflows/deploy-gh-page-on-push.yml.

On this page