Files
SowinskiBraedenandClaude Opus 5 377f3a31b3 initial commit: reforgermods.net launcher 0.1.0
A desktop server browser for Arma Reforger, built on the reforgermods.net v2
API. Live server list with search, sort and filters; a detail inspector with
population history and mod readiness; measured ICMP latency; favorites, recents
and settings; and direct joining through Steam.

Tauri 2 and Rust, with a Preact frontend that builds to 80 KB of JavaScript in
one chunk. Logic lives in rfm-core, which has no Tauri dependency and is tested
directly; src-tauri is the window, the command surface and the wiring.

Packaged for the public beta: a per-user NSIS installer, a portable .exe and an
Arch package, with signature-verified background updates on Windows and a
release pipeline that builds from a tag. The artifacts are not yet
Authenticode-signed — that is wired and waiting on an Azure Trusted Signing
identity, and docs/distribution.md says so plainly rather than letting anyone
discover it at the SmartScreen dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 20:29:00 -07:00

267 lines
11 KiB
YAML

# Build, sign and publish a launcher release.
#
# Triggered by a version tag. The tag is the instruction: the job refuses to
# build unless the tag and every version in the tree already agree, because an
# updater manifest that disagrees with the installed version either loops
# forever or never fires (docs/distribution.md section 6).
#
# The Windows build runs here rather than cross-compiled from Linux, even though
# scripts/build-windows.sh works: NSIS bundling and Authenticode signing both
# want Windows tooling, and the signing credential should live in one place that
# is not a laptop.
#
# Stored under .github/workflows so both GitHub Actions and Gitea Actions pick it
# up, matching the other two repositories.
name: Release
on:
push:
tags: ['v*']
# Reruns of a published tag, for when a publish step failed and the artifacts
# are fine. Builds only; nothing is published without a tag.
workflow_dispatch:
inputs:
version:
description: 'Version to build, without the leading v (e.g. 0.1.0)'
required: true
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
windows:
name: Windows installer
runs-on: windows-latest
# A protected environment: put the signing and R2 credentials on it, and add
# required reviewers so publishing a release needs a human.
environment: release
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: '. -> target'
# The tag is the source of truth for what is being released; the tree has
# to already say the same thing.
- name: Check the version agrees with the tag
id: version
shell: bash
run: |
tag="${{ inputs.version || github.ref_name }}"
scripts/version.sh check "$tag"
echo "version=$(scripts/version.sh show)" >> "$GITHUB_OUTPUT"
- run: npm ci
# Re-validated here rather than trusting that CI ran on this commit: a
# release is the one build that must not ship a known-broken tree.
- name: Checks
run: |
npm run check
npm test
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p rfm-core
# Signing is optional so the pipeline works before Azure Trusted Signing is
# provisioned. When the secrets are absent the build is simply unsigned, and
# the summary says so — rather than the whole release failing, or worse,
# silently producing an unsigned installer that the notes call signed.
- name: Decide whether artifacts can be signed
id: signing
shell: bash
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
RFM_SIGN_ENDPOINT: ${{ vars.RFM_SIGN_ENDPOINT }}
run: |
if [ -n "$AZURE_CLIENT_ID" ] && [ -n "$RFM_SIGN_ENDPOINT" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::warning::Azure Trusted Signing is not configured; this release will be UNSIGNED and will show the SmartScreen warning."
fi
- name: Install the signing tool
if: steps.signing.outputs.enabled == 'true'
run: cargo install trusted-signing-cli --locked
# TAURI_SIGNING_PRIVATE_KEY is the updater key, which is a different thing
# from Authenticode code signing: it proves an update came from this project,
# while Authenticode is what stops SmartScreen calling the download unknown.
# Both are needed and they solve different problems.
- name: Build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
RFM_SIGN_ENDPOINT: ${{ vars.RFM_SIGN_ENDPOINT }}
RFM_SIGN_ACCOUNT: ${{ vars.RFM_SIGN_ACCOUNT }}
RFM_SIGN_PROFILE: ${{ vars.RFM_SIGN_PROFILE }}
shell: bash
run: |
if [ "${{ steps.signing.outputs.enabled }}" = "true" ]; then
# The overlay adds only bundle.windows.signCommand, so Tauri signs each
# artifact during bundling and the updater zip contains a signed
# installer.
npm run tauri -- build --config src-tauri/tauri.signing.conf.json
else
npm run tauri -- build
fi
# The portable exe is the same binary the installer wraps, offered for people
# who will not run installers. It costs nothing to publish.
- name: Collect artifacts
shell: bash
run: |
set -euo pipefail
version="${{ steps.version.outputs.version }}"
mkdir -p dist/release
nsis="target/release/bundle/nsis"
cp "$nsis"/*-setup.exe "dist/release/reforgermods-launcher-${version}-setup.exe"
cp "target/release/reforgermods-launcher.exe" "dist/release/reforgermods-launcher-${version}.exe"
# The NSIS bundle is signed in place, so the updater artifact is the
# installer itself plus a detached .sig. It exists only when the signing
# key was available; an unsigned release simply has no updater entry.
for f in "$nsis"/*-setup.exe.sig; do
[ -e "$f" ] || continue
cp "$f" "dist/release/reforgermods-launcher-${version}-setup.exe.sig"
done
ls -l dist/release
# Uploaded whether or not publishing happens, so a failed publish never
# means rebuilding.
- uses: actions/upload-artifact@v4
with:
name: windows-${{ steps.version.outputs.version }}
path: dist/release/
if-no-files-found: error
retention-days: 14
# The updater key is read through env rather than interpolated into the
# script: `${{ secrets.X }}` in a run block writes the secret into the shell
# command itself, and only the log is masked.
- name: Summary
shell: bash
env:
HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }}
SIGNED: ${{ steps.signing.outputs.enabled }}
VERSION: ${{ steps.version.outputs.version }}
run: |
{
echo "### Launcher $VERSION"
if [ "$SIGNED" = "true" ]; then
echo "- Authenticode: **signed**"
else
echo "- Authenticode: **UNSIGNED** — expect the SmartScreen warning"
fi
if [ "$HAS_UPDATER_KEY" = "true" ]; then
echo "- Updater: artifacts signed, \`latest.json\` will be published"
else
echo "- Updater: **no key configured**, no updater artifacts produced"
fi
} >> "$GITHUB_STEP_SUMMARY"
publish:
name: Publish to R2
needs: windows
runs-on: ubuntu-latest
# Only a tag publishes. A manual run builds and stops, so it can be used to
# check a release without touching what is live.
if: startsWith(github.ref, 'refs/tags/v')
environment: release
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: windows-${{ needs.windows.outputs.version }}
path: dist/release
# The Arch package is not built here: it needs an Arch userspace (see
# scripts/build-arch.sh, which runs one in a container) and is handed over
# per tester rather than published. Drop it into dist/release before the
# manifest step if that changes.
- name: Build the manifests
run: |
python3 scripts/release-manifest.py \
--version "${{ needs.windows.outputs.version }}" \
--artifacts dist/release \
--base-url "https://dl.reforgermods.net" \
--out dist/release
- name: Checksums
working-directory: dist/release
run: |
sha256sum -- * > SHA256SUMS || true
# The manifests are generated from the artifacts, so they must not be
# listed as if they were part of the signed set.
grep -v -E '(releases|latest)\.json|SHA256SUMS' SHA256SUMS > sums.tmp
mv sums.tmp SHA256SUMS
cat SHA256SUMS
# dl.reforgermods.net is an R2 bucket with a custom domain. Versioned paths
# are immutable; only /updates/latest.json and /releases.json move, which is
# what makes a rollback a matter of re-publishing one small file.
- name: Publish
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
BUCKET: reforgermods-launcher-downloads
run: |
set -euo pipefail
version="${{ needs.windows.outputs.version }}"
put() {
npx --yes wrangler@4 r2 object put "$BUCKET/$1" --file "$2" --content-type "$3" --remote
}
for file in dist/release/*; do
name="$(basename "$file")"
case "$name" in
releases.json|latest.json) continue ;;
*.exe) type="application/vnd.microsoft.portable-executable" ;;
*.zip) type="application/zip" ;;
*.zst) type="application/zstd" ;;
*) type="text/plain; charset=utf-8" ;;
esac
put "$version/$name" "$file" "$type"
done
# Last, and in this order: the updater manifest only starts pointing at a
# release once every file it names is already in place.
put "updates/latest.json" dist/release/latest.json "application/json"
put "releases.json" dist/release/releases.json "application/json"
- name: Next step
run: |
{
echo "### Published ${{ needs.windows.outputs.version }}"
echo "Artifacts are live under \`https://dl.reforgermods.net/${{ needs.windows.outputs.version }}/\`."
echo ""
echo "The website's download panel reads its own copy of the manifest."
echo "Copy \`releases.json\` from this run's artifacts into"
echo "\`public/static/launcher/releases.json\` in reforgermods-web and deploy,"
echo "or the page will keep offering the previous release."
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: manifests-${{ needs.windows.outputs.version }}
path: |
dist/release/releases.json
dist/release/latest.json
dist/release/SHA256SUMS