scmJS docs

Releases

.github/workflows/build.yml has three channels, and the release list only grows when a version is cut:

channel trigger what lands
ci every push to main lint, tests, and the web bundle built and thrown away. Nothing is deployed and nothing is released.
nightly a daily cron at 07:17 UTC, or a manual dispatch with the nightly input ticked installers for Windows, macOS x64/arm64 and Linux AppImage/deb, a zip of the web bundle, and electron-updater's latest*.yml, all on one rolling prerelease — plus that same zip unpacked onto nightly.editor.scmjs.dev.
stable a pushed vX.Y.Z tag a permanent release with the same assets, its notes (see below), the container image on GHCR, and the Pages deploy of editor.scmjs.dev.

tsc -b covers desktop/ through tsconfig.desktop.json, so a main-process type error fails a push to main; only the packaging step waits for the nightly. The cron exits early when main has not moved since the last one, so an idle week produces no builds.

The nightly is updated in place: the nightly tag is force-moved to the commit and the assets are replaced with gh release upload --clobber. It is never deleted and recreated — that would reset the download counts and re-notify everyone watching releases. So there is one prerelease in the list, permanently, however many nights run.

GitHub redirects /releases/latest/download/<asset> to the newest release that is not a prerelease or a draft. The download buttons on the site are therefore plain <a href>s that never need updating, never touch the API (and so cannot be rate-limited), and never resolve to a nightly:

https://github.com/scm-js/scm-js/releases/latest/download/scmJS-windows-x64-setup.exe
https://github.com/scm-js/scm-js/releases/latest/download/scmJS-windows-x64.zip
https://github.com/scm-js/scm-js/releases/latest/download/scmJS-macos-arm64.dmg
https://github.com/scm-js/scm-js/releases/latest/download/scmJS-macos-x64.dmg
https://github.com/scm-js/scm-js/releases/latest/download/scmJS-linux-x86_64.AppImage
https://github.com/scm-js/scm-js/releases/latest/download/scmJS-linux-amd64.deb
https://github.com/scm-js/scm-js/releases/latest/download/scmJS-web.zip

and the nightly's own, on its fixed tag:

https://github.com/scm-js/scm-js/releases/download/nightly/scmJS-windows-x64-setup.exe

That redirect can only resolve a fixed file name, which is why electron-builder.yml's artifactName carries no version. The cost is that two downloaded versions share a name in the browser's downloads folder; the version is in the release title, the notes and latest*.yml. To un-promote a bad stable release, tick "This is a pre-release" on it and the redirect falls back to the one before — no new tag, no rebuild.

The container image#

docker/Dockerfile is nginx with the built web bundle in it, pushed to ghcr.io/scm-js/scm-js by the image job on a tagged release only:

docker run --rm -p 8080:80 ghcr.io/scm-js/scm-js:latest   # http://localhost:8080

The tags are latest, the full version and the moving X.Y and X (docker/metadata-action reads them off the tag the run is on). A nightly publishes no image: an installer is something you choose and can roll back by keeping the old file, while latest in a registry is what a docker run picks up without asking, so only a version cut on purpose goes there.

Two decisions are worth knowing:

Locally, npm run build:image builds the bundle and the image (tagged scmjs). The Release workflow builds and serves the image before it tags anything — a request for the page, one hashed asset and a check that /tileset/manifest.json 404s — so a broken Dockerfile stops the release instead of landing on latest.

Cutting a release#

Run the Release workflow (.github/workflows/release.yml) from the Actions tab. It takes the version to release — 0.3, 1.0 and 1 are accepted and padded to three parts, and blank promotes the line the nightlies have already been building, so what people have been testing is what ships — an optional notes box (below), and a dry_run tick that does everything except push.

It refuses to run anywhere but main, refuses a version whose tag exists, refuses one that is not newer than the last release (going backwards would offer nobody an update and would still take over the /releases/latest redirect, since GitHub calls the most recently published release the latest), and runs the same lint, tests and build the Build workflow does before writing anything — a workflow that stops before tagging costs nothing, while a tag pointing at a failing commit has to be deleted by hand. Then:

  1. package.json and the lock go to the released version and are committed. The whole diff is "version", in the two files. Before the very first release there is nothing to commit and the tag goes on the commit that is already there.
  2. vX.Y.Z (annotated) tags that commit.

There is no third step moving package.json on, and that is the point of scripts/next-version.mjs. A nightly is named <base>-nightly.<date>.<run number>, and the base is a patch bump of the newest release tag — worked out, never recorded. So package.json on main means something true and self-maintaining: the version that was last released.

Why a patch bump and not a guess at the next real version: a nightly has to sort above the release it follows (or the in-app updater offers nightly users a downgrade it cannot install) and below the release that comes next (or it offers them nothing until that version finally ships). A patch bump is the only choice that can never be too high — after v0.8.0 the nightlies are 0.8.1-nightly.…, which is below 0.8.1, 0.9.0 and 1.0.0 alike. Nothing has to be decided in advance about what comes next: cut 1.0.0 whenever the breaking change lands and every nightly user is offered it. The two edges, both tested in tests/next-version.test.ts: with no release tags nothing has shipped, so package.json's own version is used as it stands, and a prerelease tag (v1.0.0-beta.1) answers with its release version, since 1.0.0-nightly.… sorts above 1.0.0-beta.1 and below 1.0.0.

Release notes#

A tagged release's body is what someone wrote, followed by GitHub's generated list of commits and pull requests. What someone wrote comes from one of two places:

Neither is required. With nothing written the release carries the generated list alone, as every release did before; the Release workflow says so as a notice in its pre-flight, where it also prints the notes it found, so a dry run shows exactly what the release will read like. docs/releases/README.md is the convention.

The Build workflow is then dispatched on the tag rather than left to the tag push, because a push made with the repository's own GITHUB_TOKEN starts no further workflow run (GitHub's recursion guard) — workflow_dispatch through the API is the documented exception, and dispatching on refs/tags/vX.Y.Z puts Build in exactly the state the push would have — and that one dispatch is the whole release, the hosted editor included, since Pages now serves the tag. (A PAT in a secret would make the tag push trigger Build directly; this needs no secret.)

Versions#

The version comes from the tag, or scripts/next-version.mjs plus -nightly.<date>.<run number> on main; the workflow warns when a tag and package.json disagree. Every job npm versions it into package.json before building, vite.config.ts injects that as __APP_VERSION__, src/version.ts is where the splash and the About dialog read it, and electron-builder writes it into latest*.yml. The run number rather than the short SHA orders same-day nightlies, since semver compares alphanumeric prerelease identifiers lexically. package.json in the repository is only ever set by the Release workflow, so a clean checkout builds as the last released version rather than as a number nobody chose.

No build carries game data or an address to fetch it from, and CI has no game data, so the real-data test suites skip there.

The two hosted builds#

editor.scmjs.dev is the newest tag and nightly.editor.scmjs.dev is main as of the last nightly. The point of the split is that everything the project ships is now a version you can name: the hosted editor, the installers, the container image and the release notes are one build, so "it's broken on the website" has an answer.

deployed by from
editor.scmjs.dev the pages job, on the stable channel GitHub Pages on this repository
nightly.editor.scmjs.dev the nightly-site job, on the nightly channel one force-pushed orphan commit on scm-js/nightly's gh-pages branch

The nightly site is the release's own web zip unpacked, never a second build, the way the container image is. The push is a single orphan commit each time, so that repository stays the size of one bundle instead of growing by 5 MB a night, and it carries CNAME (where a branch-served Pages site keeps its custom domain, so it has to be in every push) and .nojekyll.

They are separate origins, which is deliberate and not free. The extracted game data lives in OPFS and every scmjs. setting in localStorage, both scoped to the origin, so the nightly asks for the game data again and keeps its own preferences, recents, installed plugins and plugin code snapshots. What that buys is a nightly that cannot write a stored shape the stable build then reads back — worth more than the second download, for a channel whose whole job is to be ahead of the stable one.

To put editor.scmjs.dev back on an older version, dispatch Build on that tag: the deploy is the only thing a re-run rewrites. Un-promoting a release (ticking "This is a pre-release") moves the download redirect but not the site.

Three repository variables and one secret, none of them required — a fork builds and releases without any of them:

PAGES_BASE the hosted build's base path; / for a custom domain, and the default is the repository name. When it is not / the web job builds the bundle a second time, since the release zip is always rooted.
NIGHTLY_DOMAIN the domain written into the nightly site's CNAME. Unset, the deploy is skipped.
NIGHTLY_PAT (secret) a fine-grained token whose only permission is Contents: write on <owner>/nightly, because a repository's own GITHUB_TOKEN cannot write to another repository. A repository secret on this repository, not an organisation one like PLUGIN_API_PAT: only build.yml reads it, and an org secret is readable by every workflow in every repository it is shared with. Unset, the deploy is skipped with a notice rather than failing the nightly.

In-app updates#

desktop/updater.ts (main process, electron-updater), src/editor/updates.ts (the pure state machine and every string it shows), src/atoms/updateAtoms.ts, src/hooks/useUpdateCheck.ts (the startup check and the one event subscription) and components/dialogs/UpdateDialog.tsx. tests/updates.test.ts.

The feed is the latest.yml / latest-mac.yml / latest-linux.yml the desktop job uploads; electron-builder.yml's publish: block is what makes electron-builder write them and bake app-update.yml into the asar. Version comparison is the version field inside those files rather than the git tag, so the moving nightly tag is not a problem, and the version-free asset names mean the download URL under that tag is always current. electron-updater derives allowPrerelease from whether the running version has a prerelease component, so a nightly build follows nightlies and a stable build follows stable on their own; the Preferences tick (updates.nightly) overrides it so a stable install can opt in. electron-updater is required lazily — Rollup keeps it behind a memoised factory in main.cjs — because desktop/main.ts is on the critical path to the first painted frame.

Finding an update raises a toast, not a dialog. The check lands seconds after launch, by which time the user has started doing something, and two dialogs already open themselves at startup (Game Data when there is none, the Repair plugin on a map that needs it) — a third would queue behind them. The toast carries a Download button (Toast.action, the only button a toast may have) that opens the same dialog Help ▸ Check for Updates… opens, and it has no ttl, so it waits to be answered rather than expiring behind the splash.

Nothing downloads or installs unasked: autoDownload is false, and installing goes through guardedAction(store, …, "quit") — the same unsaved-changes gate as the window's close button — before quitAndInstall. autoInstallOnAppQuit stays true so "Later" on a downloaded update means "next time I quit".

Two things UpdateSupport exists to keep honest:

Verified against a packaged Linux build: an unpacked-folder run reports that it cannot check; the AppImage reaches GitHub (a 404 on a repository with no releases reads as "No release was found for this build"); and against a local generic feed the whole path runs — toast, dialog, download, "ready to install".

scmJS 0.1.0 · Generated from the repository. StarCraft and Brood War are trademarks of Blizzard Entertainment; this project ships none of their data.