Skip to content

Today I learned / DevOps and tooling

DevOps and tooling notes

CI, hosting, Firebase, Git and the command line: the one-line fixes and settings that save an afternoon the next time.

Why doesn't SetLive put my Steam build live on the default branch?

"SetLive" in a SteamPipe app_build script only works for beta branches. Steam refuses to set the default branch live automatically: that last step is always a click on the app's Builds page in Steamworks.

json
"AppBuild"
{
  "AppID" "1000"
  "Desc" "CI build"
  "SetLive" "staging"   // a beta branch, never "default"
  "Preview" "0"         // "1" = dry run: logs and manifest only, nothing uploaded
  "Depots" { "1001" "depot_build_1001.vdf" }
}
bash
steamcmd +login <account> +run_app_build ../scripts/app_build.vdf +quit

So my pipeline uploads every build straight to a private staging branch, I play it there, and promoting it to default stays a deliberate manual step. That is a feature, not a gap: CI can never ship to all players by itself. Also note that the account setting builds live on a released app needs Steam Guard via a phone or the mobile app.

Source: Steamworks: Uploading to Steam

YouTube API: containsSyntheticMedia not set after upload

I upload AI-voiced Shorts through the YouTube Data API with status.containsSyntheticMedia: true in videos.insert. The upload works, but the "Altered content" flag in Studio stays off.

The fix is to set the flag in a second call, videos.update on the status part. That call replaces the whole status object, so send every field again. Any field you leave out goes back to its default.

js
await fetch('https://www.googleapis.com/youtube/v3/videos?part=status', {
  method: 'PUT',
  headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
  body: JSON.stringify({
    id: videoId,
    status: {
      privacyStatus: 'private', // resend, or it changes
      selfDeclaredMadeForKids: false,
      embeddable: true,
      license: 'youtube',
      containsSyntheticMedia: true,
    },
  }),
})

Check status.containsSyntheticMedia in the response and warn if it is still false. Calls made right after a fresh upload can return 409 ABORTED or a 5xx for a while, so retry them with backoff.

Hit this while building the bot that turns my TIL notes into Shorts with a cloned voice.

Docs: https://developers.google.com/youtube/v3/docs/videos#status.containsSyntheticMedia

How do I restart a Node.js container when its event loop hangs?

A Node server stuck in an endless loop answers nothing, and Docker won't restart it: a failing healthcheck only marks it unhealthy, and restart: acts only when the process exits. Nothing on the stuck thread can notice, so the watchdog lives on a worker thread and kills the process from there.

ts
// main thread
const w = new Worker(new URL('./watchdog.js', import.meta.url), { workerData: 30_000 })
w.unref()
setInterval(() => w.postMessage(0), 1000).unref()

// watchdog.js (worker)
let last = Date.now()
parentPort.on('message', () => { last = Date.now() })
setInterval(() => {
  if (Date.now() - last > workerData) process.kill(process.pid, 'SIGKILL')
}, 1000)

The gotcha: in a container your server is PID 1, and the kernel won't deliver that SIGKILL to PID 1. Add init: true to the service in Compose so the server runs as a normal child.

I added this to a game's WebSocket gateway, where every player socket hangs on one thread. Keep it off in development: a breakpoint looks exactly like a hang. Docs: Compose init.

Newsletter

Occasional notes on engineering, game tech, and new tools — no spam, unsubscribe anytime.

Ravy

Engineering manager, founder, and software developer building web platforms, game systems, real-time services, and automation tools. 15 years of shipped work — currently at Gaijin.net.

Get in Touch

Building an MVP, shipping a game feature, or automating a team workflow? Open source or paid — let's talk.

Contact Me
© 2020-2026 XPLOIT FZE. All trademarks, names and logos belong to their respective copyright holders.