Automating Steam releases for HTML games with Electron Forge and Github Actions

Saman Bemel Benrud • Feb 15 2022

I spent a good chunk of the last month streamlining Wilderplace’s build and release process - read on to learn how to do the same for your HTML game.

What is Wilderplace? It’s an HTML app built with Snowpack and published to Steam and packaged into a desktop app using Electron. While it’s possible to manually build Electron distributables and upload them to Steam, the process is fraught, and I wanted no-stress deployments.

My goal was to be able to trigger a workflow that publishes new versions of my game on Windows, Linux, and Mac by pushing a git tag to my game’s Github repo. After many roadblocks, I managed to pull it off. In this post, I’ll first share my solution, and then talk through some of the obstacles I encountered.

Before I dive in, I want to give some credit to Drew Conley, author of “How to package your web project for platforms like Steam”, for providing a starting point for my own workflow.

The full solution

Here’s how Wilderplace’s repo is set up:

wilderplace/
├─ package.json
├─ .github/
│  ├─ publish.yml
├─ scripts/
│  ├─ prepare-electron.js
├─ src/
├─ build/
│  ├─ index.html
│  ├─ (css, js, ect.)
├─ electron/
│  ├─ package.json
│  ├─ app-icons/
│  │  ├─ icon.icns
│  │  ├─ icon.ico
│  ├─ out-resources/
│  ├─ src/
│  │  ├─ index.js

You’ll notice I have a second JavaScript project nested inside my root project under electron/. Electron Forge, the bundling tool I use, is rigid about repo structure. I want to be able to run my game in the browser during development, using Snowpack’s dev server, and only package for Electron as part of my release process. I chose to use a separate directory for all things Electron. I could have worked directly with Electron Packager, which would give me more flexibility about repo structure, but I like the simplicity of Electron Forge.

Important takeaways from this diagram:

  1. The root directory is a stand in for any JavaScript app with any build system. You’ll want a build system that puts your final files, including an index.html entry point, in build/. It doesn’t matter if you use a bundler or rely on Chrome’s JavaScript module system. Both styles of HTML apps work with Electron.
  2. I did not use Electron Forge’s boilerplates, but they’re worth looking into if you don’t care about being able to easily run your app outside of Electron.
  3. One consequence of this repo structure is that I need to move source files from /build to /electron/out-resources before running the Electron make command. See Preparing electron/ for the make step for details on how I do this.
  4. electron/src/index.js is my “main” Electron process file. It must be in this location to conform to Electron Forge. Learn how to create your main file from the Electron Quick Start guide.

The Electron Forge package.json file

The Electron Forge build system lives in electron/, and this build system has it’s own package.json file. A couple comments about my forge config:

  1. Steam works fine with simple ‘unpackaged’ packages so I’m using the basic .zip maker for all platforms.
  2. The packageConfig icon option takes a url template, and will look for platform-specific file endings (icns on windows, ico on mac).

Here’s a minimal version of electron/package.json:

{
  "name": "wilderplace",
  "productName": "wilderplace",
  "version": "0.0.0-alpha.10",
  "description": "A divine garden adventure",
  "main": "src/index.js",
  "scripts": {
    "make": "electron-forge make"
  },
  "config": {
    "forge": {
      "packagerConfig": { "icon": "./app-icons/icon" },
      "makers": [{ "name": "@electron-forge/maker-zip" }]
    }
  },
  "devDependencies": {
    "@electron-forge/cli": "^6.0.0-beta.63",
    "@electron-forge/maker-zip": "^6.0.0-beta.63",
    "electron": "^17.0.1",
  }
}

Preparing electron/ for the make step

I need to be able to copy files from the root project into the Electron project, and overwrite the Electron project package.json version key, before we make our distributables. To do this, I used a node script located at scripts/prepare-electron.js:

const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const copydir = require('copy-dir');
const electronPkgJson = require('../electron/package.json');
const parentPkgJson = require('../package.json');

const src = path.join(__dirname, '..', 'build');
const dest = path.join(__dirname, '..', 'electron', 'out-resources');

// Copy build dir to electron out-resources, which is where electron-forge
// looks for source files.
rimraf.sync(dest);
copydir(src, dest, {});

// Overwrite nested package.json version, because this is what electron-forge
// uses to set the version in the Electron app.
electronPkgJson.version = parentPkgJson.version;
fs.writeFileSync(
  path.join(__dirname, '..', 'electron', 'package.json'),
  JSON.stringify(electronPkgJson, null, 2)
);

Bringing it all together with a GitHub workflow file

workflow diagram

I’m using Github Actions for release automation. Here’s my workflow file, located at ./github/publish.yml:

name: Build/release

# Run this workflow whenever a tag that starts with v is pushed to github.
on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    runs-on: ubuntu-latest

    steps:
      - name: Check out Git repository
        uses: actions/checkout@v1

      - name: Install Node.js & NPM
        uses: actions/setup-node@v2
        with:
          node-version: '14.2.0'

      # Wine is required in order to generate an Electron build for Windows from
      # an Ubuntu machine.
      - name: Install Wine
        run: |
          sudo dpkg --add-architecture i386
          sudo apt update
          sudo apt install -y wine-stable

      # Move all the game files into a build/ directory.
      - name: Install and build the HTML app
        run: |
          npm ci
          npm run build

      # npm run make uses Electron Forge, see package.json above for details.
      # We have to run make three times, once for each platform. This step
      # generates three directories, corresponding to each platform.
      # Notice the working-directory is now ./electron!
      - name: Copy HTML app to electron/, and make Electron packages
        run: |
          node ../scripts/prepare-electron.js
          npm run make
          npm run make -- --platform=win32
          npm run make -- --platform=darwin
        working-directory: ./electron

      # View this action's source code and documentation at
      # https://github.com/game-ci/steam-deploy. Note that this action only
      # works in ubuntu environments. If you want to run in Mac OS or Windows
      # environments, you'll need to write your own deploy script.
      # The depot{number}Path parameters correspond to the directories created
      # in the previous step by npm run make.
      - name: Deploy to Steam
        uses: game-ci/steam-deploy@v1.1.0
        with:
          username: ${{ secrets.STEAM_USERNAME }}
          password: ${{ secrets.STEAM_PASSWORD }}
          configVdf: ${{ secrets.STEAM_CONFIG_VDF}}
          ssfnFileName: ${{ secrets.STEAM_SSFN_FILE_NAME }}
          ssfnFileContents: ${{ secrets.STEAM_SSFN_FILE_CONTENTS }}
          appId: your app id goes here
          buildDescription: ${{ github.ref }}
          rootPath: electron/out
          depot1Path: ${{ github.repository }}-darwin-x64
          depot2Path: ${{ github.repository }}-win32-x64
          depot3Path: ${{ github.repository }}-linux-x64
          releaseBranch: prerelease

That covers my basic setup. As long as your repo is structured similarly to mine, and you’ve got a build step in your root app that places all source files including an index.html file in build/, this setup should work for you.

Challenges

Building cross platform distributables

An earlier version of my build script ran on a build matrix including macos-latest and win32-latest, which I assumed was the only way to build for each platform, until I read the Electron Packager docs more closely. Turns out you can build for every platform from Ubuntu as long as you install Wine. This allowed me to simplify and speed up my action, and fully rely on game-ci/steam-deploy rather than writing my own platform-specific scripts for running steamcmd. Getting the Wine install instructions just right took a few iterations, but the command in the workflow file above has been working reliably.

Setting up Steam Guard authentication from Mac OS

In order to run steamcmd as part of a Github action, you need to be authenticated with Steam’s two factor authentication system, Steam Guard. The contributors behind steam-deploy came up with a good solution to authenticating on Github Actions machines, but the documentation is from the point of view of a Linux user. If you’re running Mac OS, config/config.vdf and ssfn<numbers> will be located at ~/Library/Application\ Support/Steam/. Besides these file locations, steam-deploy’s documentation should apply to Mac OS users.

For more details on authenticating steamcmd with Steam Guard, check out Steam’s build script documentation.

Platform specific issues

Every platform came with its quirks. Some gotchas:

  • Early in my process, I hit a mysterious issue where Mac OS builds of the game would immediately crash in Steam. It turns out there is a bug with the Steam GUI uploader that will break symlinks inside Mac OS Electron apps. See electron-builder/#5767 for details. I avoided this problem by using steamcmd.
  • On Windows, the Electron Window constructor options are confusing. To get a nice full screen game experience, here are the window settings I landed on:
      const mainWindow = new BrowserWindow({
        autoHideMenuBar: true,
        titleBarStyle: 'hidden',
        fullscreen: true,
        /*...other settings not related to window sizing*/
      });
    
  • On Linux Arch, the game only ran the first time. Subsequent starts did nothing. As far as I can tell, the only workaround is to add the --no-sandbox launch option argument.

Understanding Steamworks concepts

Steamworks is Steam’s publishing toolkit. It’s pretty confusing. Plus, as far as I can tell there’s no way to totally automate working with builds, depots, installation options, or publishing. This Reddit comment from u/PirateHearts is the best explanation I found of how things work, give it a read.

My basic workflow in the Steamworks UI is as follows:

  1. Each platform (Mac, Windows, Linux) has its own depot configuration and ID, set up from the partner.steamgames.com/apps/depots/{depotid} page.
  2. Each platform has its own “Launch option”, set up on the partner.steamgames.com/apps/config/{depotid} page.
  3. Since all my builds include files for all my Depots, when I want to publish, I go to the ‘builds’ page and set my preferred build’s live branch to ‘default’.

Next steps

One thing I haven’t dove into is integration with Steam-specific APIs like acheivements and the Steam overlay. I briefly looked at https://github.com/greenheartgames/greenworks but was put off by the install instructions, it’s implementation as a Node.js addon, and some of the bug reports I’ve seen from Electron users. I’d love to hear how other game devs managed to work with Steam APIs in Electron apps.

Now that Wilderplace has a great CD system up and running, we’ll be shifting from Alpha to Beta, with all future pre-release testing happening through Steam. Stay tuned for more Beta news soon!


I hope this overview of Wilderplace’s publishing setup is useful for other folks who want to release HTML games on Steam! I’d love to hear feedback or ideas for how to improve or generalize my solution. Let me know on Twitter at @samanbb.

Wilderplace can now be wishlisted on Steam! The best way to keep up with the game’s development or request access to the Alpha is to join the discord.