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.
Download links#
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:
- It copies a bundle rather than building one. The
webjob has already built, linted and tested the zip the release carries; the image job downloads that artifact and unzips it, so the image cannot differ from the download. It also makes--platform linux/amd64,linux/arm64free — with noRUNstep there is nothing to emulate, and the files are the same bytes on either architecture, so buildx writes one manifest list and needs no QEMU. A node build stage would have cost minutes per architecture to produce identical output. - It carries no game data.
vite buildcopiespublic/intodist/, so a clone that rannpm run extracthas Blizzard's extracted trees sitting in the bundle;.dockerignorecuts them back out of the build context rather than trusting them to be absent, and the nginx config answers 404 for those five paths so the manifest probe gets an honest answer. A container starts at step 4 of the resolver and asks through Help ▸ Game Data…, exactly like the hosted build. Mounting your own tree over those paths is in game-data.md.
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:
package.jsonand 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.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:
docs/releases/<version>.md, committed tomainbefore the release is cut and read by the Build workflow out of the tag's own tree. The name is the full three-part version, so cutting0.3readsdocs/releases/0.3.0.md. This is the normal way: the notes are reviewed like any other change and stay with the commit they describe.- The Release workflow's
notesinput, for a one-off not worth committing. It wins over the file and is handed to Build as a dispatch input, so it lives only in the release — re-running Build on that tag by hand falls back to the file.
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:
- Checking and installing are separate questions.
support.checkis the updater's ownisUpdaterActive(), not a guess fromprocess.platform, becauseAppImageUpdater— the implementation chosen for any Linux build with nopackage-typefile — refuses whenAPPIMAGEis not in the environment, and thencheckForUpdates()resolves null instead of throwing. Reading that null as "up to date" told the user they were current when nothing had been fetched;check()maps it tounsupported.support.installis false only on macOS, where Squirrel.Mac verifies the code signature and an unsigned build cannot apply what it downloaded. Windows (NSIS), the AppImage and the.deb(through dpkg or apt, asking for privileges) all install. Whereinstallis false the dialog offers the release page, never a progress bar that would fail at the end. - electron-updater's errors carry the whole HTTP response, response headers and
Set-Cookieincluded.message()keeps the first line, names the cases worth naming (404, 403, 5xx, the socket errors) and caps the rest, so a failed check is one sentence and no session cookie reaches the screen.
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".