Part of Kus' modded Counter Strike 2 (CS2) Dedicated Server: https://github.com/kus/cs2-modded-server/
Automates the manual "check releases, download, copy into the repo, bump the README,
commit" routine for the mods listed in this repo's root README.md version table
(Mod | Version | Why). No AI involved, safe to re-run, and it refuses to act when
anything looks off.
Always run from a checkout of this repo (the script cds to the repo root itself, so
relative paths work from anywhere inside the repo):
./scripts/update/update.sh --dry-run # report only: what is outdated and exactly what would be done
./scripts/update/update.sh # apply every available update, one commit per mod
./scripts/update/update.sh --only "Inventory Simulator" --only counterstrikesharp # limit to some mods (name or slug)
./scripts/update/update.sh --list # mods, current versions, slugs and which have an update script
./scripts/update/update.sh --no-pull # skip the pull a local run starts withA local run pulls first. The GitHub Action commits mod updates straight to stage,
so a local checkout goes stale fast. Before reading any version the script fetches and
fast-forwards the current branch, otherwise it would read the stale README, decide mods
are outdated when the Action has already done them, and redo the work. It only ever
fast-forwards, never merges or rebases:
| Situation | What happens |
|---|---|
| Up to date | Nothing, just says so |
| Behind, clean tree | Fast-forwards, then carries on |
| Behind, dirty tree | Warns that versions may be stale and does not pull (a real run stops on the dirty tree anyway) |
| Diverged from the remote | Stops, so you resolve it by hand |
| No upstream, or detached HEAD | Warns and carries on |
| Inside GitHub Actions | Skipped; the runner already checked out the exact ref |
--no-pull runs against the local checkout as-is, for working offline or on a branch
you do not want moved.
Requirements: bash (3.2+ is fine), git, curl, jq, unzip, tar, rsync
(macOS openrsync works). A clean working tree is required for a real run because
the tool commits; commit or stash first. The GitHub API is used only to list release
assets of mods that need updating (one call per mod). Unauthenticated that is
limited to 60 requests/hour; set GITHUB_TOKEN, or be logged in with gh auth login
(the token is picked up automatically), to lift the limit.
Exit code is 0 when nothing failed (up to date, updated, or only "no script"
warnings) and 1 when any mod failed or the run was stopped.
For every row of the README version table, top to bottom, one mod at a time:
- Check the latest version with the same functions as
scripts/check-updates.sh(that script is sourced, not copied - the detection logic lives in one place). The localREADME.mdis the source of truth for the current version, so a mod you just updated and committed is reported as up to date on the next run. - Up to date ->
OK, next mod. No releases / not detectable ->?, next mod. - Update available and no
scripts/update/plugins/<slug>.sh-> warningno update script, listed in the summary, next mod. Nothing is downloaded. - Update available and a script exists -> plan phase (nothing is changed yet):
syntax-check and load the script, fetch the release's asset list, match every glob
from
plugin_assets()(each must match exactly one asset), runplugin_preflight(). Any failure -> flagged, next mod. - Apply phase: download the assets into
tmp/update/<slug>/<version>/(cached and size/integrity checked, so re-runs do not re-download), runplugin_apply()in an isolated subshell where the first failing step aborts the plugin, verify that every changed file is inside the script'sPLUGIN_PATHS, bump the version in the README table row (must be a single-line change), thengit commit -m "- UPDATED: <Mod> <old> > <new>"with only those paths staged. Extracted folders are deleted; downloaded archives are kept intmp/(git-ignored). - If applying fails after files were touched, the run stops (later mods are not processed) and the working tree is left as-is so you can inspect it. The message tells you what was wrong and how to reset.
--dry-run performs steps 1-4 fully (network reads only) and then prints every step
of 5 as [dry-run] would ... without downloading, extracting, writing or committing.
Checks against extracted archive contents cannot run in dry-run and are printed as
would verify ....
.github/workflows/auto-update-mods.yml runs this same script daily at 3pm
Australia/Sydney, and on demand from the Actions tab. It adds nothing of its own: it
checks out stage, runs ./scripts/update/update.sh, and if that produced commits it
pushes them and opens a pull request into master. Adding a new plugin script here is
therefore all that is needed for the automation to pick that plugin up too.
- No update, no noise. If the script commits nothing, the workflow pushes nothing and opens no pull request.
- A human merges. Commits pushed with the Actions token do not trigger other
workflows, so if the bot merged into
masterthebuild and publishworkflow would silently not run. Merging the pull request yourself does trigger it. - Failures are surfaced. If the script exits non-zero the pull request is still opened for the mods that succeeded, the summary is attached, and the job then fails so the run shows up as a failure.
- The schedule is a single cron at 04:00 UTC. GitHub cron has no timezone, so in Sydney that lands at 3pm during daylight saving and 2pm outside it.
- Scheduled workflows only fire from the copy of the file on the default branch, so
this workflow has to be on
masterto run on its own. - GitHub disables scheduled workflows after 60 days with no repository activity.
The script file name is the slug of the mod name in the README table: lower-case,
every run of non-alphanumerics replaced by -, trimmed. --list prints it for every
mod, e.g. Metamod:Source -> metamod-source.sh, CS2 Retakes -> cs2-retakes.sh.
Create scripts/update/plugins/<slug>.sh from this template. It is sourced by
update.sh, so its top level must only set PLUGIN_PATHS and define functions.
Before writing it, work through "Reconstructing the process for a mod from git history"
and the gotchas list below, then test it as described in "Testing a new or changed script".
#!/usr/bin/env bash
# Project: Kus' modded Counter Strike 2 (CS2) Dedicated Server - https://github.com/kus/cs2-modded-server/
# Update script for: <Mod name exactly as in the README table>
# README row: [<name>](<url>)
# Sourced by scripts/update/update.sh - do not run directly.
#
# Manual process this automates:
# 1. ...
# Every repo path this update may create, change or delete (files or directories).
# Used for: refusing deletes/writes elsewhere, verifying nothing else changed, git add.
PLUGIN_PATHS=(
"game/csgo/addons/counterstrikesharp/plugins/<PluginDir>"
)
# One glob per line, matched against the release's asset file names. Each must match
# exactly one asset. $NEW_VERSION is available (e.g. "Foo-v${NEW_VERSION}.zip").
plugin_assets() {
echo "<Plugin>-*.zip"
}
# Optional: checks on the repo before anything is downloaded or changed.
plugin_preflight() {
require_dir "game/csgo/addons/counterstrikesharp/plugins"
}
# The steps. Use only the helpers below; each one aborts the plugin on failure.
# Pattern: extract everything, verify the archive layout, then change the repo.
plugin_apply() {
local x
x=$(extract_asset "<Plugin>-*.zip")
require_dir "$x/addons/counterstrikesharp/plugins/<PluginDir>"
remove_path "game/csgo/addons/counterstrikesharp/plugins/<PluginDir>"
copy_dir "$x/addons/counterstrikesharp/plugins/<PluginDir>" \
"game/csgo/addons/counterstrikesharp/plugins/<PluginDir>"
remove_extracted "$x"
}Then test it: ./scripts/update/update.sh --dry-run --only <slug> and, once happy,
./scripts/update/update.sh --only <slug>. Check the commit with git show --stat.
Every existing script was derived this way. It takes ten minutes and avoids guessing.
- Find the manual updates:
git log --oneline --grep="UPDATED: <Mod name>", thengit show --name-status --format="" <sha>for the last three. This tells you the real repo paths (they are often NOT where you expect - many plugins live underplugins/disabled/because they are off by default) and which files change per update. - Get the release layout: list the assets with
curl -s https://api.github.com/repos/<owner>/<repo>/releases/latest | jq -r '.assets[].name'(orgh api), download the one(s) the manual updates used, and list the archive (unzip -Z1 x.zip/tar -tzf x.tar.gz). Note the root:addons/...,csgo/addons/...,<Name>/plugins/...,<Name>/...- it differs per project. Ignore junk (README-ME.txt,logs/,-no-map-configsvariants,-with-cssharp-*bundles,steamrt4builds). - Diff the extracted archive against the repo (the repo is normally at the latest
version):
diff -rq <extracted>/<dir> game/csgo/.... Identical files = what the manual process copies. Differing files = what it keeps (customised configs). Files only in the repo = leftovers the manual process never deleted, or custom files. - Check the config history:
git log --oneline -- game/csgo/cfg/<mod>(andconfigs/plugins/<Mod>). Commits that are not "UPDATED: " mean it is customised. Customised config paths are NEVER written by a script (see the config policy below); usewarn_cfg_dir_changes/warn_new_settingsinstead. - Pick the copy style from steps 1-3: "replace whole" (
remove_path+copy_dir) when the repo folder is byte-identical to the release; "merge" (copy_dironly) when the repo carries leftovers the manual updates kept (MatchZy, GG2) or when a file name only differs by case. - Write the script from the template, put the reconstructed manual process in its header
comment (that is the documentation of record), pin asset names with
${NEW_VERSION}where the name carries the version, and addrequire_*checks for the archive layout you saw.
- Asset variants: releases often ship several files; pin the exact one the manual updates
used (
steamrt3notsteamrt4,with-runtime, plainMatchZy-<v>.zip, fullRetakesPlugin-<v>.zipnot-no-map-configs). A glob that matches 2 assets fails on purpose. - Version in the name: tags are normalised like
check-updates.shdoes (v1.6->1.6,V0.5.1->0.5.1, Metamod2.0.0.1469/git1469->2.0.0-1469), so${NEW_VERSION}is the bare version. Some assets carry no version at all (Deathmatch.zip): use the exact name. - Two-platform mods: apply Windows first, then Linux, always. Both archives ship the same
config/vdf files and the Linux copies (LF endings, Linux paths) are the ones the repo keeps.
Only replace the binaries the manual updates replaced; the per-platform
.vdfs in the repo live elsewhere (addons/windows/,addons/surf/) and are managed by hand. - Case-only renames (now guarded, see
verify_no_case_duplicates, so a run refuses to commit one): the repo is updated on both macOS (core.ignorecase=true) and Linux (the GitHub Action), so a file must only ever be tracked under one case. MatchZy renamedlang/pt-pt.jsontolang/pt-PT.json; the Action's merge-copy addedpt-PT.jsonnext to the old file, and on macOS the two then fought over one path (a permanent "modified" file that discard just flips). The stalept-pt.jsonwas untracked; check withgit ls-files | sort -f | uniq -di(must print nothing) and untrack the stale name withgit rm --cached(a plaingit rmon macOS deletes the surviving file too). - Same link text elsewhere in the README: the version bump only matches the row in the
Mod | Version | Whytable (anchored at line start), because e.g. the CounterStrikeSharp link also appears in prose. - Files inside plugin folders that look like config (
map_config/,spawns/,lang/) are upstream-owned and byte-identical to the release here, so "replace whole" is fine - but check step 3 before assuming that for a new mod. - A leading
vin the README version never matches: detection strips non-digits from the tag, so a row reading`v1.2.4.b3`compares against1.2.4.b3and reports an update on every run, forever. jRandomSkills was the only row with avand it did exactly that. Keep the bare version in the table however upstream writes its tags. - Trust the files, not the commit message, for what version the repo holds: the
jRandomSkills row claimed
v1.2.4.b4, a version that has never existed upstream, with no such release or tag. Its binaries were byte-identical to v1.2.4.b3, so the label was simply wrong and a run would have "updated" the repo backwards. Compare the tracked binaries against the release before trusting a version string. - A case-only rename that already reached a branch must be undone on every branch, not
just the one you noticed it on. While
mastertracked both names andstagetracked one, every macOS checkout ofmastershowed a permanently modified file. Merging the fixed branch in removes the stale entry, but since both names were one physical file the merge deletes it from disk, so finish withgit checkout -- <surviving path>. If the merge refuses because the dirty file is the one being deleted, discard it first withgit checkout -- <stale path>, which flips the dirtiness onto the surviving name, then merge. Verify withgit ls-files | sort -f | uniq -di, which must print nothing. - The archive root can move between versions: CS2 Deathmatch shipped
Deathmatch/{plugins,shared}/up to 1.3.5 and the standardaddons/counterstrikesharp/{plugins,shared}/from 1.3.6, with identical file names inside. Therequire_*checks caught it before anything was written, so the failure was clean, but the run still stopped. Usepick_dirto accept both roots rather than hard coding one, and keep the oldest supported root as the last candidate. - A README bump done in a separate commit (cs2-quake-sounds 26.08.1) means the manual
commit's file set lacks
README.md; the script always bumps it in the same commit.
Never test on the real checkout; the tool commits. Use a throwaway local clone:
git clone -q --local --branch stage . /tmp/cs2-test && cd /tmp/cs2-test
git revert --no-edit <sha of the mod's last "UPDATED:" commit> # makes the update pending again
# if the revert conflicts on README.md context, instead: git checkout <sha>~1 -- <the mod's paths>,
# set the README row back by hand with sed, and commit
./scripts/update/update.sh --dry-run --only <slug>
./scripts/update/update.sh --only <slug>
git show --name-status --format="" HEAD | sort > /tmp/new.txt
git show --name-status --format="" <sha> | sort > /tmp/old.txt && diff /tmp/old.txt /tmp/new.txt # expect no diff
./scripts/update/update.sh --only <slug> # second run must report "OK <mod> <version>" and change nothingDownloads are cached under tmp/update/<slug>/<version>/; copy archives there from another
checkout to avoid re-downloading. Delete the clone afterwards.
| Variable | Meaning |
|---|---|
PLUGIN_NAME, PLUGIN_URL |
Name and URL from the README row |
OLD_VERSION, NEW_VERSION |
Version in the README and the detected latest version |
PLUGIN_SLUG |
The slug (also the script's base name) |
DOWNLOAD_DIR, EXTRACT_DIR |
tmp/update/<slug>/<version> and .../extracted |
DRY_RUN |
1 in dry-run (helpers already honour it; is_dry is the test) |
| Helper | Does |
|---|---|
extract_asset <glob> |
Extracts the matched archive into a fresh EXTRACT_DIR/<archive name>/ and prints that path |
asset_path <glob> |
Prints the path of the downloaded archive |
pick_dir <dir> [dir...] |
Prints the first candidate that exists, for releases whose archive root moves between versions. Dies if none do, so a third layout is still caught. In dry-run it assumes the first. |
remove_extracted <dir> |
Deletes a directory made by extract_asset |
require_dir <path>, require_file <path> |
Fail unless it exists (reported as would verify for extracted paths in dry-run) |
sync_dir <src> <dest> |
rsync -rhavz --exclude "._*" --exclude ".DS_Store" --partial --stats src/ dest/ (merge, never deletes at dest) |
copy_dir <src> <dest> |
Copy a directory to <dest> (created if missing; same rsync excludes) |
copy_file <src> <dest> |
Copy one file (parent directory created) |
empty_dir <dir> |
Delete everything inside <dir>, keep the directory |
remove_path <path> |
Delete a file or directory; no error if already absent |
verify_no_case_duplicates |
Run automatically after every plugin's apply: fails the run before committing if two paths differ only in case. Not called from plugin scripts. |
warn_cfg_dir_changes <archive dir> <repo dir> [existing-only] |
Heads-up only: lists new upstream config files, repo files upstream no longer ships, and new settings in files present on both sides. Writes nothing. |
plugins/<slug>.ignore (file, optional) |
One setting key per line (# comments allowed). Keys listed here are never reported by the two helpers above - add a key once it has been reviewed (ported, or removed from your config on purpose) so it stops showing up on every update. |
warn_new_settings <archive file> <repo file> |
Heads-up only: lists setting keys (.cfg first tokens / JSON keys) present upstream but missing from the repo's customised copy |
info, step, warn, err, die |
Logging; die aborts the plugin with a message |
Deletes (empty_dir, remove_path) are only allowed inside a PLUGIN_PATHS entry.
Writes are allowed inside an entry or to an ancestor of one (so sync_dir "$x/addons/" "game/csgo/addons/" works); anything that ends up changed outside PLUGIN_PATHS is
caught after plugin_apply and blocks the commit.
Sources other than GitHub releases and the Metamod:Source download page need
plugin_resolve_assets() in the script, printing one name<TAB>url<TAB>size line per
downloadable asset (size 0 if unknown).
| Mod | Script | Notes |
|---|---|---|
| Metamod:Source | plugins/metamod-source.sh |
Assets come from the dev downloads page quick-download links. Windows zip is applied before the Linux tar.gz on purpose: both ship metamod.vdf, metamod_x64.vdf, metaplugins.ini, README.txt; the Linux copies (LF endings, linux64 server path) are what the repo keeps. |
| CounterStrikeSharp | plugins/counterstrikesharp.sh |
Uses the with-runtime Windows and Linux zips. Windows first (rsync into game/csgo/addons/, then the special game/csgo/addons/windows/addons/counterstrikesharp/ refresh of api/ bin/ dotnet/), then Linux rsync on top. |
| Inventory Simulator | plugins/inventory-simulator.sh |
Replaces the plugin directory and the inventory-simulator.json gamedata file. |
| MultiAddonManager | plugins/multiaddonmanager.sh |
Windows zip + Linux steamrt3 tar.gz (the steamrt4 build is ignored). Replaces only the two binaries in game/csgo/addons/multiaddonmanager/bin/; the .vdf is left alone and the customised game/csgo/cfg/multiaddonmanager/multiaddonmanager.cfg is never written, only compared (new settings reported). |
| ServerListPlayersFix | plugins/serverlistplayersfix.sh |
Windows zip + Linux steamrt3 tar.gz. Replaces only bin/win64/*.dll and bin/linuxsteamrt64/*.so; the repo's per-platform .vdf copies are left alone. |
| MovementUnlocker | plugins/movementunlocker.sh |
Windows zip + Linux steamrt3 tar.gz. Replaces only bin/win64/*.dll and bin/linuxsteamrt64/*.so; the .vdf copies under addons/surf/ are left alone. |
| CS2 Retakes | plugins/cs2-retakes.sh |
Uses the full RetakesPlugin-<v>.zip (not the -no-map-configs one). Replaces plugins/disabled/RetakesPlugin (the plugin is kept disabled in this repo, so it is NOT at plugins/RetakesPlugin) and shared/RetakesPluginShared. |
| MatchZy | plugins/matchzy.sh |
Plain MatchZy-<v>.zip (not the -with-cssharp-* bundles). Merge-copies the plugin folder over plugins/disabled/MatchZy (nothing deleted). game/csgo/cfg/MatchZy/ and custom_files_example/cfg/MatchZy/ are customised and never written; new upstream files/settings are only reported for a manual port. |
| GunGame | plugins/gungame.sh |
Merge-copies plugins/disabled/GG2 and shared/GunGameAPI (old leftovers like Dapper.dll/runtimes/ are kept, as the manual updates did). game/csgo/cfg/gungame/ is customised and never written; new upstream files, removed files and new settings are reported for a manual port. |
| CS2 Deathmatch | plugins/cs2-deathmatch.sh |
Asset is always Deathmatch.zip. Archive root is addons/counterstrikesharp/ from 1.3.6 and Deathmatch/ before it; pick_dir accepts either. Replaces plugins/disabled/Deathmatch and shared/DeathmatchAPI whole. configs/plugins/Deathmatch/ and gamedata/Deathmatch.json are not shipped and not touched. |
| deathrun-manager | plugins/deathrun-manager.sh |
Replaces plugins/disabled/DeathrunManager whole. The shipped configs/plugins/DeathrunManager/DeathrunManager.json is never written, only compared (new settings reported). Archive junk (README-ME.txt, logs/) ignored. |
| RollTheDice | plugins/rollthedice.sh |
Archive root RollTheDice/; replaces plugins/disabled/RollTheDice whole. configs/plugins/RollTheDice/ not touched. |
| CS2-FixRandomSpawn | plugins/cs2-fixrandomspawn.sh |
Asset renamed to CS2-FixRandomSpawn.zip in 1.2.0 (FixRandomSpawn.zip before). 1.2.0 also restructured the archive to plugins/ + gamedata/, relative to addons/counterstrikesharp/; the older flat layout is not supported. Plugin lives at plugins/FixRandomSpawn (not disabled) and is replaced whole, which also drops the stale plugin-local gamedata/ the flat archive left behind. The top-level gamedata/FixRandomSpawn.json is upstream signature data and is replaced, because 1.2.0 renamed its keys and it has to match the dll. |
| jRandomSkills | plugins/jrandomskills.sh |
Asset is jRandomSkills.v<version>.zip, archive laid out relative to addons/counterstrikesharp/ (plugins/ + gamedata/). Plugin lives at plugins/disabled/jRandomSkills and is replaced whole, which drops the two .pdb files that v1.2.4.b3 no longer ships. Its configs/ are byte-identical to upstream, so they are replaced rather than protected. Release tags carry a v prefix, so the README row must hold the bare version. |
| cs2-quake-sounds | plugins/cs2-quake-sounds.sh |
Archive root QuakeSounds/; replaces plugins/disabled/QuakeSounds whole. configs/plugins/QuakeSounds/ not touched. |
update.sh- entry point: argument parsing, preflight, the per-mod loop, summary.lib.sh- all shared functions (logging, path guards, GitHub/Metamod asset resolution, download/extract, repo write helpers, README bump, git commit).plugins/*.sh- one script per mod (see above).../check-updates.sh- unchanged behaviour when run directly. It gained a guard so it can be sourced, andCHECK_UPDATES_README=<path>to read a local README instead of the one on GitHub master (that is whatupdate.shsets).
working tree is not clean- commit or stash, then re-run.no update script for '<Mod>' - expected scripts/update/plugins/<slug>.sh- create it (see above).asset pattern '<glob>' matched N assets- the release's file names changed; the message lists what is available, fixplugin_assets().no release in <repo> has a tag matching version '<v>'- the version scraped from the GitHub releases page and the API disagree (e.g. a pre-release or a renamed tag). Look at the release page.Metamod asset <name> is version .../could not find the quick-download links- the metamodsource.net page layout changed; fixmetamod_release_assetsinlib.sh.expected directory does not exist: tmp/update/...- the archive layout changed; extract it by hand, compare, and adjust the plugin script.unexpected change outside the plugin's paths: <path>- either add the path toPLUGIN_PATHS(if the update legitimately owns it) or fix the script. Nothing was committed; reset withgit checkout -- . && git clean -fd game/.the update did not change any tracked file under PLUGIN_PATHS- the release ships identical files or the script copies to the wrong place. Nothing was committed.expected exactly one row in the README.md mod table starting with [<name>](<url>)- the README row was edited; the link text/URL must match the table row exactly.GitHub API error ... rate limit- setGITHUB_TOKENorgh auth login.
- Two copy styles are in use on purpose: "replace whole" (
remove_path+copy_dir) where the repo folder is byte-identical to the release, and "merge" (copy_dironly) where the manual history shows leftovers were never removed (MatchZy, GG2). - Config policy: plugin scripts never write config files that can be customised. Anything a
release ships under
cfg/orconfigs/(MatchZy, GunGame, MultiAddonManager, deathrun-manager) is only compared withwarn_cfg_dir_changes/warn_new_settings, and new files, removed files and new settings are printed for a manual port. The files that ARE overwritten are upstream-owned data shipped inside the plugin/addon folders (Metamod's stockmetaplugins.iniand.vdfs, CounterStrikeSharp'sgamedata.json,*.example.jsonandlang/, Retakes'map_config/, Deathmatch'sspawns/, Inventory Simulator's gamedata) - all byte-identical to upstream in this repo. If one of those ever gets customised, move it out of the script's paths or switch that script to a report-only check. - The new-settings report is a key-based heuristic (first token of a
.cfgline, JSON keys) on customised files, so it also lists upstream keys that were removed from the repo's copy on purpose and upstream example values (e.g. example SteamIDs in MatchZy'sadmins.json). Silence reviewed keys viaplugins/<slug>.ignore; the file is not created automatically. - Design decisions: versions are read from the local README (idempotency); mods
without a script are only reported, nothing is downloaded for them; the run stops at
the first failure that left the tree dirty, but continues past failures that changed
nothing; exactly one commit per mod, message
- UPDATED: <Mod> <old> > <new>, no trailer/footer. run_isolatedinlib.shexists because bash ignoresset -einside anything called from anif/||/&&condition, including subshells that re-enable it. Never callprocess_mod/run_isolatedfrom such a context or a failing step would not abort the plugin. Verified on bash 3.2 and 5.2.- Log output goes to fd 3 so helpers can print return values on stdout and be captured
with
$(...). extract_mods(fromcheck-updates.sh) is wrapped inlist_modswithpipefailoff: its awk exits at the end of the table and the SIGPIPE oncatwould otherwise abortupdate.shwith exit 141.- The rsync flags are the ones from the manual process minus
--progress(pure noise for local copies). - Version strings are normalised exactly like
check-updates.sh(normalize_version). Metamod:Source2.0.0-1469corresponds to asset namesmmsource-2.0.0-git1469-*.