Prerequisite: Continuous integration — which suites run where.
Everything is published under https://www.danielhobi.ch/dreamrefactory/, and a release is a tag. Nothing deploys from an ordinary push to master except the documentation.
Six builds and a doc set share that one hosting directory, and each goes out on its own:
| tag | build | lands at |
|---|---|---|
site-v0.1.1 |
npm run build -w site |
/dreamrefactory/ — the front door and the eight format editors |
taoot-v0.9.53 |
npm run build -w taoot |
/dreamrefactory/taoot/ — Titanic's four pages (the front page, /play/, /collection/, the unlisted /speedrun/) |
dust-v0.3.6 |
npm run build -w dust |
/dreamrefactory/dust/ — Dust's three pages (the game, /collection/, the unlisted /speedrun/) |
timelapse-v0.1.0 |
npm run build -w timelapse |
/dreamrefactory/timelapse/ — Timelapse's one page |
skullcracker-v0.1.0 |
npm run build -w skullcracker |
/dreamrefactory/skullcracker/ — Skull Cracker's two pages (the films and its menu, walk.html) |
redjack-v0.1.1 |
npm run build -w redjack |
/dreamrefactory/redjack/ — RedJack's one page |
lunicus-v0.1.0 |
npm run build -w lunicus |
/dreamrefactory/lunicus/ — Lunicus's one page |
jumpraven-v0.1.0 |
npm run build -w jumpraven |
/dreamrefactory/jumpraven/ — Jump Raven's one page |
| (no tag) | npm run docs:build |
/dreamrefactory/docs/ — on any push that touches docs/ |
# in the package that is releasing:
npm version 0.9.1 --no-git-tag-version -w @dreamfactory/taoot
# commit and merge, then from master:
npm run release -- taoot # or several: taoot dust timelapse skullcracker redjack lunicus jumpraven
npm run release # everything whose version has no tag yet
npm run release -- --dry-run # what it would do, and nothing elseDo not let npm version cut the tag. It writes a bare v0.9.1, which no
pattern here listens for — the tag would push and deploy nothing at all,
silently. --no-git-tag-version keeps it to the files and leaves the tag to the
hand that knows which of the six it is.
Do not git push --tags a multi-game release. GitHub creates no workflow
run at all when more than three tags arrive in a single push — not merely the
excess ones. Nothing warns you: the push succeeds, every tag is on the remote,
and the Actions tab is empty. A four-game release hits it exactly; recovery is
one workflow_dispatch run per tag.
npm run release
(tools/release.mts)
pushes one tag per push, and after each one waits for the deploy to appear
in the Actions tab — dispatching it by hand and saying so if it does not. It
refuses to tag
anything but a clean master that matches its remote, and it spells each tag from
the package's own version, which is the pairing deploy.yml re-checks before it
uploads.
Every namespace carries its target's name, and a tag naming none of them fails
the run rather than defaulting to one, since a default would let a mis-typed
tag ship the wrong build. deploy.yml can also be run
from the Actions tab, where a workflow_dispatch input picks the target.
Four lines in deploy.yml and nothing else, because everything after the target
is resolved is driven by the name: npm run build:<target> writes
dist/<target>, and that directory is mirrored to <target>/. The four are the
push.tags trigger, the workflow_dispatch choices, the concurrency group and
the shell that turns a tag into a target — and
site/tests/deploy-lanes.ts
fails when a game in the registry is missing from any of them, because three of
the four ways to get it wrong are silent.
The manifest is written by npm run manifest -w <game> — or by npm run manifest,
which fans out over every package that has one — or by mkmanifest.ts run inside
the game's directory on the host. All of them produce the same keys; a run from
the root must not prefix every key with the game's own directory, or the page
indexes nothing (site/tests/manifest-keys.ts).
What the workflow cannot do is put the RIP there. A runner has no game data, so
the manifest the build writes describes almost nothing and is deleted before the
upload; a freshly deployed game shows its "no game data" page until the host's own
copy and a manifest generated beside it (tools/mkmanifest.ts) are in place, so
deploying a new game is two steps.
Because the mirror only adds and overwrites — see below. The site's build writes the root of the tree and the four games write directories inside it, so none of the five can remove another's files. Asset names are content-hashed, so a superseded bundle is dead weight rather than a stale page.
taoot/package.json, dust/package.json, timelapse/package.json, skullcracker/package.json, redjack/package.json, lunicus/package.json, jumpraven/package.json, site/package.json |
the sources of truth — semver |
each package's vite.config.ts |
substitutes its own for __APP_VERSION__ at build time |
site/src/version.ts |
exports VERSION, and draws it in the top bar beside the wordmark |
site/src/bug-report.ts |
puts it in the issue body, so a report names the build it came from |
A page belongs to exactly one package and reads exactly one number. Node does no substitution, so a test or
a tool that imports version.ts reads 0.0.0-dev rather than throwing.
The deploy fails if the tag and the package disagree: the pages read their number from the manifest, and a site announcing a version nobody tagged is worse than a failed deploy.
Each game's directory holds things that are in no build and never in this repository:
*/gamefiles/ |
the CD rips | 7.4 GB (Titanic) and 645 MB (Dust), gitignored forever |
*.zip |
the offline DBGL archives the collection page links to | ~1 GB apiece |
*/gamefiles.json |
the listing of a rip | see below |
nightdive.mov is not one of them: since #171
it is generated and deployed, and reaches the host like lang.stg does.
The film is not in git — taoot/assets/nightdive.gif is, and a Vite plugin
compiles the MOV into taoot/public/ at build time.
So the mirror only adds and overwrites. There is no --delete and no option
to turn one on: re-uploading a wrong file costs a minute, and a deleted 7 GB rip
does not. The files that could in principle be written over are excluded from
the transfer as well (.github/actions/ftp-mirror,
which every deploy in the repository goes through).
gamefiles.json is the listing every page reads to find out what game data
exists (the manifest). A build writes it by walking that game's
gamefiles/ — and a GitHub runner has no rip, so the file it produces holds a
handful of entries against the 4,172 (Titanic) or 460 (Dust) the host
serves. Uploading it would leave the page offering nothing, so the workflow
deletes it before the transfer and excludes it besides.
The host's copy has to be regenerated whenever the game data there changes, or
when this repository adds an authored DF file to a package's public/. Do it
where the tree is, once per game:
cd …/dreamrefactory/taoot && npx tsx …/tools/mkmanifest.ts . ./gamefiles .
cd …/dreamrefactory/dust && npx tsx …/tools/mkmanifest.ts . ./gamefiles .The third argument is where the authored files (lang.stg, nightdive.mov) are:
public/ in a checkout, but the game's directory in a deployment, because that
is where public/ is served from.
Each game's tree has its own listing, so Dust's page downloads a 20 KB listing of its own disc rather than a slice of Titanic's 212 KB index.
The account details live on the dreamrefactory environment; FTP_HOST is a
repository secret and falls through to it, because it is the same server for
everything.
| Secret | where | |
|---|---|---|
FTP_HOST |
repository | the host to connect to — a hostname, no scheme and no path |
FTP_USER |
environment | |
FTP_PASSWORD |
environment | |
FTP_PATH |
environment | . — the account lands in the site directory already |
FTP_PORT |
either | optional, 21 if unset |
FTP_INSECURE_TLS |
either | optional escape hatch, see below |
FTP_ALLOW_PLAINTEXT |
either | optional escape hatch, see below |
gh secret set FTP_USER --env dreamrefactory # and FTP_PASSWORD, FTP_PATHThe first four are required; the run fails with a named error before it connects if one is missing. The environment exists so that the account scoped to this directory cannot be reached by anything else.
Plain FTP sends the password as text on the wire, so the upload demands TLS:
AUTH TLS on the ordinary FTP port, which is explicit FTPS — what a shared host
usually means by "FTP over SSL". Passive mode, because the runner is behind NAT.
One connection rather than several, because shared FTP accounts cap concurrent
logins.
FTP_HOST is s067.cyon.net, not www.danielhobi.ch: the FTP server's certificate is a real one but it names the provider
(*.cyon.net), so connecting by the domain fails verification while connecting
by the server's own name passes it.
If cyon ever moves the account to another machine the connection will fail
outright — check dig -x on the site's address for the new server name and
update the secret, or fall back to www.danielhobi.ch with FTP_INSECURE_TLS=1.
Two escape hatches for a host that cannot do TLS properly. Both are off by default and both print a warning into the run when used:
FTP_INSECURE_TLS=1 |
still encrypted, but the certificate is not checked. The usual shared-hosting case: the cert names the server rather than the domain |
FTP_ALLOW_PLAINTEXT=1 |
no TLS at all. The password crosses the wire in the clear — a last resort, and worth asking the host about first |
The password never reaches a command line: the action writes the lftp script to
$RUNNER_TEMP at mode 600 and deletes it on exit.
docs.yml publishes docs/ on any push that touches it. Docs are not versioned
against a game — a correction to a format page should be readable the day it is
written.
Unlike the five builds, the docs site cannot be path-independent: VitePress needs
an absolute base for its router, so docs/.vitepress/config.ts names
/dreamrefactory/docs/ outright. It is the one place in the repository that knows
the deployment's URL. It sits under docs/ rather than at the root because a
VitePress site owns its whole route namespace — and this doc set has an
editors/ section that would land exactly on top of the editors application.
Back to Reference.