MuffMode Release Process

August 13, 2026 ยท View on GitHub

README | Build Guide | Configuration Reference | Changelog

This project is currently in Beta. The release script defaults to -Channel beta, names packages with the -beta suffix, and publishes GitHub releases with the prerelease flag. GitHub does not allow prereleases to be marked as the repository's "Latest" release, so only stable releases force the latest flag.

This project uses VERSION as the release version source of truth. The in-game mod version in src/sgame/g_local.h must match it before publishing a release.

The release script is scripts/release.ps1. It builds a beta zip package shaped like the muffmode-0.22.15-beta.zip release asset, plus a Windows installer asset:

Run it with PowerShell 7 (pwsh). The script enforces that prerequisite before doing version, build, or packaging work.

muffmode-<version>-beta/
  README.html
  README.de.html
  README.pl.html
  README.fr.html
  README.hu.html
  README.bg.html
  CHANGELOG.md
  LICENSE
  THIRD_PARTY_NOTICES.md
  MuffModeUpdater.exe
  rerelease/
    baseq2/
      game_x64.dll
      muffmode-version.json
      muffmode.version
      ...
muffmode-<version>-beta.zip
muffmode-<version>-beta-windows-installer.exe
muffmode-<version>-beta-map-sources.zip

GitHub also adds the automatic source-code archives. Discord announcements keep the primary download list intentionally short and ordered as the installable muffmode-<version>[-channel].zip, Windows installer, map sources, then Source Code. Supplemental original-map archives may still be uploaded for preservation, but they are not listed as primary Discord downloads.

Release notes are generated from the central changelog ledger, not from commit subjects. The script still prefers GitHub Copilot CLI for the English README.html in non-interactive mode. If Copilot is not installed or authenticated, it falls back to a styled deterministic English HTML README. When Copilot is available, the script translates that guide into German, Polish, French, Hungarian, and Bulgarian as README.de.html, README.pl.html, README.fr.html, README.hu.html, and README.bg.html, validating protected code snippets, commands, cvars, config names, links, key constants, and visible prose before the installer is assembled. Without a Copilot token, local packaging ships a self-contained English-only guide; pass -RequireCopilot when a release must fail instead of omitting localized guides. Pass -RequireCopilot when you intentionally want the release to fail unless Copilot also generates the original English README, instead of using the deterministic English fallback. It also writes rerelease/baseq2/muffmode-version.json and rerelease/baseq2/muffmode.version so the Windows updater and launcher can compare the installed version with GitHub releases. The package also includes the published Windows updater and launcher executable at the package root.

The staged ZIP must remain acceptable to the last public pre-self-update updater. Release packaging therefore generates inert legacy gt-*.cfg markers required by v0.60.20 and validates that exact nested bridge inventory. Modern updaters and the installer skip those files. Do not remove the bridge until the supported-upgrade floor no longer includes that updater generation; adding a new package-owned host filename must not expand the deployed updater's stable bootstrap-required set.

This bridge makes the mod payload installable through v0.60.20, but that build cannot replace its own running executable and predates server-config backups. Release notes must direct those users to preserve customized gt-*.cfg files and bootstrap the current updater once with the Windows installer or a manual replacement while the old updater is closed.

Changelog Ledger

docs/changelog.md is the source of truth for release notes, release highlights, package CHANGELOG.md, GitHub release notes, and the Discord announcement summary.

Required table columns:

ColumnPurpose
ReleaseUnreleased until the release script stamps the shipped version, such as 0.36.21.
CategoryOne of the approved public release categories: player experience, competitive play, server hosting, gameplay and balance, maps and content, fixes, documentation and packaging, or internal maintenance.
Magnitudemajor, minor, or patch. This is release-note impact, not a promise that the semantic version must move by the same word.
SummaryA grouped user-readable change title. Similar work should share one row.
DetailsA practical explanation of what changed and why it matters.

Before finishing any implementation, inspect the existing Unreleased rows. If the work belongs with an existing row, rewrite that row's summary/details/category/magnitude instead of adding a duplicate. The release script validates the table, rejects duplicate summaries within the same release/category, and refuses to package a release with no matching Unreleased or target-version rows.

CI runs scripts/ci/check-changelog.ps1 to validate the table. On pull requests, implementation-facing changes must include a docs/changelog.md update with at least one Unreleased row.

Highlight rules are strict: release highlights include only major rows. If a release has no major rows, the generator falls back to the most relevant smaller rows and says that no major changes are logged.

When -UpdateVersionFiles is used, the script updates VERSION, src/sgame/g_local.h, and every Unreleased changelog row to the target release version.

The Windows installer is built with Inno Setup 6. The release script looks for ISCC.exe on PATH, then in the normal Inno Setup install folders. You can pass -InnoSetupCompiler "C:\Path\To\ISCC.exe" to override detection or -SkipInstaller for a zip-only local package. The wrapper validates the packaged DLL/updater and generated installer as Windows PE images, preserves the Inno Setup compiler log beside the release output, and fails publishing if the expected installer is missing when installer generation was not skipped.

Release State

ChannelPackage suffixGitHub release flag
alpha-alphaPrerelease, not latest
beta-betaPrerelease, not latest
rc-rcPrerelease, not latest
stablenoneLatest release

Use the default beta channel until the project is ready to leave beta.

Version Modes

ModeBehavior
autoInspects changes since the previous release and chooses major, minor, or patch. Breaking-change markers select major; new features, installer/updater/package/workflow additions, commands, cvars, maps, rulesets, gametypes, menu, or voting work select minor; smaller fixes and docs select patch. If the committed source version is already at or above the detected target, it is kept.
majorBumps from the latest release to the next major version.
minorBumps from the latest release to the next minor version.
patchBumps from the latest release to the next patch version.
latchAccepted as a patch alias.

You can also pass -Version 0.23.0 to use an exact version.

Prepare Version Files

Use this when the selected target version does not match VERSION and GAMEMOD_VERSION:

.\scripts\release.ps1 -VersionMode patch -UpdateVersionFiles -SkipBuild -AllowDirtyPackage

Commit the version and changelog stamp changes before publishing a GitHub release.

Add Release-Only Assets

Place package-only files in packaging/release-assets using the final package layout. For example:

packaging/release-assets/
  rerelease/
    baseq2/
      server-base.cfg
      CONFIGS_README.md
      factories.cfg
      mapdb.json
      muffmode-map-pool.json
      muffmode-map-cycle.txt
      lobby-casual.cfg
      lobby-competitive.cfg
      lobby-party.cfg
      lobby-horde.cfg
      maps/*.ent
      maps/*.bsp
    bots/navigation/*.nav

The script copies these files into the generated package, then overwrites/adds the built DLL, README.html, localized README.<language>.html guides, CHANGELOG.md, LICENSE, and THIRD_PARTY_NOTICES.md.

Build A Local Package

Run from a Visual Studio developer shell:

.\scripts\release.ps1 -VersionMode auto

Normal release.ps1 builds compile with warnings as errors. Use -SkipBuild only when build\msbuild\x64\Release\game_x64.dll already exists. Published skipped-build paths also require muffmode-build-receipt.json beside the DLL, binding its SHA-256 digest, strict build setting, and source commit. A normal release.ps1 build writes this receipt automatically; the release workflow writes it immediately after the strict build. A local unpublished package may reuse a DLL without a receipt, but emits a warning and cannot be promoted with -CreateGitHubRelease until rebuilt through a receipt-producing path. Use -SkipUpdaterBuild only for a local, unpublished package when MuffModeUpdater.exe already exists under the updater publish output. GitHub Actions and -CreateGitHubRelease reject updater reuse because that executable has no commit-bound build receipt. Use -SkipInstaller only when you intentionally want to create the zip without the Windows installer asset.

The package and generated release files are written to dist/release.

Windows Installer

The installer is generated from packaging/installer/muffmode-installer.iss. It installs the same payload as the zip into the outer Quake II folder, not directly into rerelease or baseq2.

Installer behavior:

ChoiceDefault path
SteamC:\Program Files (x86)\Steam\steamapps\common\Quake 2
Epic Games StoreC:\Program Files\Epic Games\Quake 2
GOGC:\GOG Games\Quake II
Xbox app / Microsoft StoreDetected app install path when available
Custom or another libraryUser-selected folder

The installer checks Steam's install registry and libraryfolders.vdf, Epic Games Store manifest files, GOG registry/common install locations, and Xbox app / Microsoft Store candidates before showing the store choices. It displays the selected path before the folder page and still offers an Other location choice for custom libraries. The installer shows the project license, lets users browse after choosing a preset, warns if rerelease\baseq2 is not found, corrects accidental rerelease or baseq2 folder selection back to the outer Quake II folder, rejects existing reparse points or junctions along every payload and backup destination branch, bounds automatic config/DLL backup size and count with a matching disk-space reserve, prompts users to close applications holding files open, offers Desktop and Start menu shortcuts for the updater, launcher, install guide, changelog, and server config guide, verifies installed files, writes an install receipt, and backs up existing server configs plus rerelease\baseq2\game_x64.dll under rerelease\baseq2\MuffModeBackups before replacing them. The installed root also includes THIRD_PARTY_NOTICES.md.

Silent installer runs should pass /DIR="C:\Path\To\Quake 2" explicitly. In silent mode the installer preserves the supplied destination and refuses to continue unless it points at an outer Quake II folder containing rerelease\baseq2.

Updater And Launcher

The release script publishes the Windows updater and launcher automatically and includes MuffModeUpdater.exe in every release package. To publish a self-contained executable manually:

dotnet publish updater\MuffMode.Updater\MuffMode.Updater.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true

See the Updater Guide for the updater workflow and local version marker behavior.

Publish From GitHub Actions

Use the Release Muff Mode workflow in GitHub Actions for normal releases. It validates free-form dispatch values through step-scoped environment variables, requires previous_tag to resolve through the exact local refs/tags namespace, updates and commits version files when requested, resolves the resulting commit to an immutable SHA, and checks out and verifies that exact SHA in the build job. Every third-party action is pinned to a reviewed commit, Node.js, .NET, Copilot CLI, Inno Setup, and vcpkg are fixed and runtime-checked where applicable, and release credentials are not exposed during tool installation. The workflow reruns the tooling/dependency/regression contracts plus host and updater tests, builds the Release x64 DLL with warnings as errors, and records the exact DLL hash in a commit-bound build receipt. Packaging then runs scripts/release.ps1 with -SkipBuild so the verified strict DLL is reused instead of being replaced by a second non-strict build. The workflow publishes a freshly built updater, builds the Windows installer with the verified compiler path, creates or verifies the exact version tag at the packaged SHA, publishes with --verify-tag, uploads the package zip, installer, map-source archive, and supplemental original-map archive, and posts the Discord announcement.

Every package records the exact source commit and whether its source tree was dirty in rerelease/baseq2/muffmode-version.json. Release output also includes a provenance JSON document that binds the clean source commit to every binary asset's name, size, and SHA-256 digest, plus a standard SHA256SUMS.txt covering those assets and the provenance document. The publish job derives the one exact asset set from the selected version and channel, requires every provenance property and asset record exactly once, recomputes every digest, and rejects missing, duplicate, extra, dirty, or commit-mismatched metadata before uploading any GitHub release asset. Local -CreateGitHubRelease publishing likewise targets the recorded commit explicitly. -Prerelease cannot be combined with -Channel stable, because the updater intentionally recognizes prereleases only by their -alpha, -beta, or -rc package suffix.

Protect v* release tags against update and deletion in repository tag rules. The publisher handles a benign create race by re-reading the tag and accepting it only when annotated-tag peeling reaches the packaged source commit, but tag protection is what prevents another writer from moving the ref between verification and release creation.

GitHub's workflow graph shows jobs, not individual steps. This workflow is intentionally split into visible release jobs: Preflight, Resolve Version, Build And Package, Publish GitHub Release, and Announce On Discord.

Use the separate Validate Release Workflow action when you only want to verify workflow startup and version resolution. It never builds, publishes, or announces a release, which keeps dry-run validation out of the actual release graph.

Release Go/No-Go

Before publishing, run the hardening gates in Hardening Guide. The release workflow repeats its release-local dependency, tooling, corpus, host-test, updater-test, and strict-build subset against the exact package commit. A release is blocked if dependency notices are stale, generated artifacts are tracked, required tests fail, CodeQL/analysis findings are untriaged, sanitizer support regresses, or the package is missing LICENSE / THIRD_PARTY_NOTICES.md.

Required repository secrets:

SecretPurpose
COPILOT_GITHUB_TOKENOptional. Fine-grained user PAT used only by the standalone copilot CLI. It must belong to a user with GitHub Copilot access and include the Copilot Requests permission. Without it (and with require_copilot unchecked), the release ships an English-only README and skips localized translations.
DISCORD_RELEASE_WEBHOOKDiscord webhook consumed by the release announcement job. The release workflow checks that it exists before publishing.

Optional repository variables:

VariablePurpose
DISCORD_RELEASE_EMOJIEmoji prepended to the Discord announcement headline. Defaults to <:quake2:1174744083403657316>.
DISCORD_RELEASE_MENTIONSText appended to the Discord announcement headline. Defaults to <@&1424165484491964667> <@&1390287267276525628> for the @quake2 and @playtester roles. Use Discord role mention IDs such as <@&123456789> if you want actual role notifications.
DISCORD_FEEDBACK_CHANNELChannel link used in the feedback line. Defaults to <#1509926054175834133> for #muffmode.

RELEASE_BOT_TOKEN is accepted as a legacy fallback for Copilot authentication, but the built-in GITHUB_TOKEN handles version-file commits and gh release create. Write permission is granted only to those version and publish jobs; the build job is read-only. GitHub and Copilot tokens are scoped to their consuming steps and are not exposed while release tools are installed and verified. If no Copilot token is present, the package step warns and ships without localized README translations (English README only) -- unless require_copilot is checked, in which case it fails instead. Because releases created with GITHUB_TOKEN do not trigger other workflows, this release workflow posts the Discord announcement itself after the GitHub release is published. The separate Broadcast Release To Discord workflow remains useful for releases published manually through GitHub.

Workflow inputs:

InputPurpose
version_modeauto, major, minor, patch, or latch as a patch alias.
versionOptional exact version, such as 0.23.0.
previous_tagOptional changelog start tag override.
channelDefaults to beta; non-stable channels publish as prereleases.
commit_version_filesUpdates VERSION and src/sgame/g_local.h, commits, and pushes before publishing.
skip_installerCreates only the zip package when the installer is intentionally not wanted.
require_copilotFails the release if Copilot authentication is unavailable, instead of falling back to a deterministic English-only README with no localized translations. Leave disabled to allow that fallback.
release_introOptional manual intro for the GitHub release notes and Discord announcement. Leave blank to let the script generate one from the most significant changelog entries.

The release workflow downloads the self-contained Windows x64 GitHub Copilot CLI 1.0.77 platform tarball once, verifies its bytes against the reviewed SHA-512 integrity value, extracts and runs that exact executable without npm dependency resolution, and installs the reviewed Inno Setup 6.7.1 Chocolatey package. Its workflow contract rejects unpinned or re-fetched Copilot replacements. The workflow primes copilot --help before any Copilot credential or shell-visible GH_TOKEN is made available to later build steps. When a Copilot token is configured, COPILOT_GITHUB_TOKEN exists only inside the package step that generates the end-user HTML README and its localized German, Polish, French, Hungarian, and Bulgarian siblings. Without a token, packaging falls back to a deterministic English-only README and skips the localized siblings entirely (unless require_copilot is checked).

Publish Locally

After committing version changes and ensuring the working tree is clean:

.\scripts\release.ps1 -VersionMode auto -CreateGitHubRelease

The script creates v<version> at the packaged source commit, or rejects an existing tag that peels to any other commit, then publishes with --verify-tag. It uploads the package zip, Windows installer, map-source archive, and supplemental original-map archive, and uses the generated changelog as release notes. Stable releases pass --latest; beta, alpha, and release-candidate builds pass --prerelease --latest=false because GitHub rejects releases that are both latest and prerelease.

Changelog Scope

The changelog is generated from docs/changelog.md. Rows marked Unreleased are used for local package builds before release stamping; rows stamped with the target release version are used after -UpdateVersionFiles commits the release metadata. Pass -PreviousTag v0.22.15 to override the compare link start tag.

The compare link still uses <previous-release-tag>...v<target-version> so GitHub can show the exact commits, but public release wording comes from the grouped ledger rows. This keeps release details stable even when a feature arrives through several commits or when a commit contains both user-facing and internal work.

-PreviousTag must name an existing exact local tag; branches, HEAD aliases, and raw commit IDs are not accepted as changelog boundaries.

If -ReleaseIntro or MUFFMODE_RELEASE_INTRO is supplied, that text becomes the release-note intro and Discord intro. During a local interactive Windows release run, the script also tries to show a small text box seeded with the generated intro so maintainers can replace it before packaging. Use -NoIntroPrompt for unattended local builds.