How the auto-updating redistribution works. Everything runs on GitHub Actions — there are no external servers.
The image in the README (
architecture.jpg, Jul 2025) is a high-level sketch and may lag behind the workflows. This document is the source of truth.
A scheduled job polls Steam for new Unturned builds. When a build changes, it downloads the game, copies the managed DLLs into a per-variant folder under redist/, and opens a pull request. The PR is validated (files present, hashes match, version not a downgrade) and — if enabled — auto-approved and squash-merged. Merging to master triggers a pack-and-push of the affected NuGet package.
Same flow as a Mermaid diagram (click to expand)
flowchart TD
cron["schedule: every 15 min<br/>redist-update.yaml"] --> probe{"download_sources<br/>DepotDownloader: manifest changed?<br/>(once per Steam source)"}
probe -- no --> done([exit quietly])
probe -- yes --> dl["download_sources<br/>SteamCMD downloads the game ONCE per source<br/>uploads Managed DLLs + appmanifest + Status.json<br/>as a per-source artifact"]
dl --> upd["update_variant (parallel, no Steam login)<br/>each variant pulls its source artifact,<br/>UnturnedRedistUpdateTool copies/publicizes DLLs,<br/>writes version.json + manifest.sha256.json + .commit"]
upd --> pr["create-pull-request<br/>branch: redist-update/<variant>"]
pr --> verify["redist-verify.yaml<br/>files + SHA-256 + version monotonicity"]
verify -- pass + opted in + bot author --> merge["auto-approve + squash auto-merge"]
merge --> push["push to master →<br/>redist-publish.yaml<br/>nuget pack + push (changed variant only)"]
merge --> cleanup["redist-cleanup.yaml<br/>lock PR + delete merged branch"]
| Workflow | Trigger | Responsibility |
|---|---|---|
redist-update.yaml |
schedule (*/15) + workflow_dispatch |
Probe each Steam source once (download_sources); on change, download the game once per source, then fan out to one PR per variant (update_variant, parallel, no extra Steam logins). Files an issue if a scheduled run fails. |
redist-verify.yaml |
pull_request to master touching redist/** |
Validate the PR: required files present, SHA-256 hashes match manifest.sha256.json, version is not a downgrade. Auto-approve + enable squash auto-merge for the bot's PRs when ALLOW_AUTO_MERGE_REDIST_PR is true. |
redist-publish.yaml |
push to master touching redist/redist-*/** + workflow_dispatch |
For each variant whose directory changed in the push, nuget pack the .nuspec and push to nuget.org. |
redist-cleanup.yaml |
pull_request: closed |
Lock the PR conversation; delete the branch only if merged. |
External tool: RocketModFix/UnturnedRedistUpdateTool (net10). CLI: dotnet UnturnedRedistUpdateTool.dll <unturned_path> <redist_dir> <app_id> [--force] [--preview] [-publicize <csv>] -update-files <csv>. It copies (and optionally publicizes) the requested DLLs into <redist_dir>, and writes version.json (or version.preview.json with --preview), manifest.sha256.json, the .nuspec <version>, and a one-line .commit message.
Every variant is defined once, in .github/variants.json. All three matrix workflows load it via jq + fromJson. Each entry:
{
"variant": "client-preview",
"appId": "304930", "depotId": "304931",
"branch": "preview",
"dir": "redist/redist-client-preview",
"nuspec": "redist/redist-client-preview/RocketModFix.Unturned.Redist.Client.nuspec",
"preview": true,
"publicize": false,
"anonymous": false,
"loginId": 10003
}branch:""= Steam default branch,"preview"= Steampreviewbeta branch.preview:trueonly for the two variants that publish an-preview<build>prerelease (passes--previewto the tool → writesversion.preview.json).publicize:truefor the publicized variants (passes-publicize Assembly-CSharp.dll). The tool rewrites non-public members to public, but deliberately leavesvirtual/abstractmembers (and compiler-generated members) at their original accessibility so pluginprotected overrides still compile (see #56). Consumers of a.Publicizedpackage must set<AllowUnsafeBlocks>true</AllowUnsafeBlocks>— see the README.anonymous:true= download with Steam anonymous login (no credentials);false= use the account inSTEAM_USERNAME/STEAM_PASSWORD. The server build downloads anonymously (the anonymous account has the dedicated-server subscription); the client app's depot is not available anonymously, so client variants require the account.loginId: a unique 32-bit integer per variant, passed to DepotDownloader as-loginidso the per-source manifest probes can run concurrently on one account (see below).
The 10 variants share only 4 distinct Steam sources (2 apps × 2 branches), and Steam allows just one session per account per LoginID (concurrent same-account logins fail with AlreadyLoggedInElsewhere). So redist-update.yaml is split into two jobs and all Steam logins are confined to the first:
download_sources(one job per source) does the only Steam work. It probes the manifest once and, on change, downloads the game once viasteamcmdand uploads just the bits the tool needs (Managed DLLs +appmanifest_<appid>.acf+Status.json) as asource-<appId>-<branch>artifact. Anonymous server sources each get a uniqueconcurrencygroup → fully parallel. The two authenticated client sources share one group → serialized; with only two members, a concurrency group keeps one running + one pending and never cancels (the 3+ case GitHub does cancel cannot occur). DepotDownloader probes still use a uniqueloginIdper source. Optionally, a second Steam account lets the two client sources download in parallel too — see Optional: a second Steam account below.update_variant(one job per variant, fully parallel) never logs into Steam: each variant downloads its source's artifact, runs the tool, records the manifest id, and opens its rolling PR. The artifact's presence is the "source changed" signal (listed via the run-artifacts API, hence the job'sactions: read). No account, no contention, nothing to serialize.
This replaced an earlier per-variant matrix that forced max-parallel: 1 and had every variant re-download the whole game — 10 serial downloads instead of today's 4 (mostly parallel).
The two client sources (304930 default + preview) are the only serialized Steam step, because they share one account and concurrent logins to one account fail with AlreadyLoggedInElsewhere. To run them in parallel:
- Create a second Steam account, add the (free) Unturned license to it, and make sure it can log into
steamcmdnon-interactively (Steam Guard configured/disabled, same as the primary account). - Add repo secrets
STEAM_USERNAME_2/STEAM_PASSWORD_2. - Set repo variable
STEAM_SECOND_ACCOUNT_ENABLEDtotrue.
The load job then tags the preview client source steamAccount: 2 and gives it its own download concurrency group, so it logs into account 2 and downloads in parallel with the default client source (account 1). With the variable unset/false the behaviour is exactly as before — both client sources on account 1, serialized — so the wiring is safe to merge before the second account exists. This only speeds up runs where a build actually changed (the every-15-min probe-only runs are already fast), and it does not change each download's size (see Why steamcmd downloads, not DepotDownloader below).
Assumes exactly two client sources (one default branch + one preview branch). Adding a third authenticated source would need a third account and a tweak to the
loadjob'ssteamAccount/dlgrouplogic — otherwise it would land on account 2 and collide with the preview source.
The probe uses DepotDownloader (only to read the current manifest id), but the actual game download — the bytes that become the redistributed DLLs — uses Valve's first-party steamcmd, on purpose:
- Supply-chain trust. The libraries we republish to NuGet are assembled by Valve's own tool, not a third-party binary. DepotDownloader is open-source, pinned by commit SHA, and pulls from the same official Steam CDN (verifying every chunk against Steam's signed manifest), so the bytes are identical — but keeping the first-party tool for redistributed artifacts keeps the trusted-tool surface minimal and the provenance obvious to consumers. (Our own
redist-verifySHA-256 check is self-consistency — it hashes what was downloaded — so it would not, by itself, catch a compromised downloader.) - The build id. The redist tool reads the rollback-safety key
BuildId(see Why the build id, not the version) from steamcmd'sappmanifest_<appid>.acf. DepotDownloader neither writes that file nor exposes a build id, so asteamcmdapp_updateis what produces both the files and the.acfin one step.
This is also why we don't switch the download to DepotDownloader's -filelist — which could fetch only the managed DLLs and shrink a multi-GB download to a few MB, and would let one account download every source concurrently. The trust posture and the BuildId dependency outweigh the size win; to parallelize the client downloads we use a second account (above) instead.
The 10 redist directories pull from only 4 distinct Steam sources (2 apps × 2 branches) and publish to 6 NuGet package ids:
| Variant directory | Steam source (app / branch) | NuGet package id | Version style |
|---|---|---|---|
redist/redist-client |
304930 / default | …Redist.Client |
stable X.Y.Z.N |
redist/redist-client-preview |
304930 / preview | …Redist.Client |
prerelease X.Y.Z.N-preview<build> |
redist/redist-client-preview-old |
304930 / preview | …Redist.Client-Preview (legacy) |
stable-style X.Y.Z.N |
redist/redist-client-publicized |
304930 / default | …Redist.Client.Publicized |
stable X.Y.Z.N |
redist/redist-client-preview-publicized |
304930 / preview | …Redist.Client.Publicized |
stable-style X.Y.Z.N |
redist/redist-server |
1110390 / default | …Redist.Server |
stable X.Y.Z.N |
redist/redist-server-preview |
1110390 / preview | …Redist.Server |
prerelease X.Y.Z.N-preview<build> |
redist/redist-server-preview-old |
1110390 / preview | …Redist.Server-Preview (legacy) |
stable-style X.Y.Z.N |
redist/redist-server-publicized |
1110390 / default | …Redist.Server.Publicized |
stable X.Y.Z.N |
redist/redist-server-preview-publicized |
1110390 / preview | …Redist.Server.Publicized |
stable-style X.Y.Z.N |
Notes:
- The main
Client/Serverpackages receive both a stable version (default branch) and a prerelease version (preview branch). Consumers who opt into prereleases of…Redist.Clientget the preview build under the same package id. - The
*.Publicizedpackages similarly receive both default-branch and preview-branch builds (the preview one as a higher stable-style version). - The
*-Previewpackage ids are legacy, fed only by the*-preview-oldvariants. They are kept for backward compatibility with consumers that referenced the old standalone preview packages, from before preview builds were folded into the main package ids as prereleases.
version.preview.jsoninside the stableredist-client/redist-serverdirectories is an orphaned tracker left over from an earlier "preview embedded in stable" scheme. It is no longer updated (frozen at an old version) and is intentionally retained; the active preview metadata lives in theredist-*-previewdirectories.- The
*-preview-oldvariants and the legacy*-Previewpackage ids are retained for backward compatibility, not removed.
Verify checks that an update is a newer upstream build before auto-merging, gating on the Steam BuildId (from version.json), not the game version string. Reason: SDG occasionally rolls back a release — on the stable branch they ship the revert as a higher patch number, but on the preview branch the game version can dip while the content is reverted. Steam build ids, however, are monotonic per app+branch and increase even on a rollback (a rollback is a new, higher-numbered build). So:
BuildIddecreased → we somehow processed an older build than what's published → hard fail (a real regression).BuildIdincreased but the NuGet version dipped → a legitimate upstream rollback → allowed, emitted as a warning (not a block).
This lets the redist faithfully follow upstream rollbacks instead of getting stuck.
- Create the redist directory and its
.nuspecunderredist/(matching the existing layout). - Add one object to
.github/variants.jsonwith all fields (variant,appId,depotId,branch,dir,nuspec,preview,publicize,anonymous,loginId). Give it aloginIdnot used by any other variant.
That's it — redist-update, redist-publish, and redist-verify all derive their matrices from that file. (The workflow_dispatch variant input is a free-form string validated against variants.json, so no dropdown to update.)
- Third-party actions are pinned to full commit SHAs (with a
# vXcomment) to prevent tag-retargeting supply-chain attacks; Dependabot keeps them current. First-partyactions/*may use major tags. - Secrets (
STEAM_USERNAME,STEAM_PASSWORD, optionalSTEAM_USERNAME_2/STEAM_PASSWORD_2,NUGET_DEPLOY_KEY) are passed via stepenv:, never as inline command-line arguments. The optional second Steam account is gated by the repo variableSTEAM_SECOND_ACCOUNT_ENABLED(see Optional: a second Steam account). - Failures are not swallowed. A failed variant turns the run red; scheduled failures open/update a GitHub issue labelled
update-failure. - Publishing fails on a duplicate version by design (no
--skip-duplicate) — a repeated version signals an upstream problem.