Skip to content

Commit 6fd266c

Browse files
authored
feat: add steam-deploy plugin (game-ci deploy steam) (#123)
New plugins/steam-deploy package, thin-wrapper-migrated from a real, production Steam deployment action (deploy-to-steam/action.yml, ~1,070 lines) rather than reimplemented from scratch. Only the genuinely portable Steam-domain logic was ported - deliberately excluded everything gameclient-private: Ported (real Steam-domain logic, generically useful to any studio): - VDF generation (app build manifest + depot definition), including the file-exclusion list reflecting real hard-won Unity-build knowledge (Burst debug info, backup folders that shouldn't ship). - Local-vs-Docker steamcmd execution, with Steam config dir mounting for auth persistence in Docker mode. - SteamCMD's output-parsing success/failure heuristic: exit code alone isn't reliable (a dropped connection or depot failure can still exit 0), so this reads "Successfully finished" / BuildID / known error signatures from the actual output text, exactly as the production script does. Deliberately NOT ported (gameclient-private, does not belong in an open-source plugin): - Project-name auto-detection from path string matching and hardcoded per-project Steam AppIDs. - ProfileLoader.ps1/frameworks.yml integration. - A custom git-checkout-with-broker-token fallback (unrelated to Steam deployment anyway). - A step posting build metadata to platform.frostebite.com, a private internal dashboard. - Hardcoded drive-letter/folder-convention build-path discovery - replaced with an explicit --buildPath argument. Command: `game-ci deploy steam <buildPath> --appId --depotId [--branch] [--mode] [--steamCmdPath] [--steamConfigDir] [--extraExclusions]`. STEAM_USERNAME/STEAM_PASSWORD read from environment only, never CLI arguments (argv can leak through process listings). Two small, generic (non-Steam-specific) extensions to the plugin system were needed, since `deploy` is the first command with no associated engine: - PluginRegistry.createCommand now checks commandPlugins registered with engine: '*' after exact-engine matches, mirroring configureOptions' existing '*' handling for options plugins. - CommandFactory special-cases `deploy` to skip engine detection entirely (same pattern already used for build-unity-image) - a deploy target's contents don't carry Unity/Godot/Unreal project markers for detectEngine() to find. - cli.ts's registerCommand middleware folds yargs' named `target` positional (from `deploy <target> [buildPath]`) back into the command array passed to CommandFactory, since yargs only puts *undeclared* trailing tokens into `_` - a named positional never lands there. Caught via a real functional smoke test (not just types/unit tests): `deploy steam <path> --appId=... --depotId=...` initially failed with "Unknown arguments: appId, depotId" because configureOptions was silently never reached; fixed, then reverified with the same command end-to-end (VDF files written with correct paths, fails at the expected final step - no steamcmd installed on this dev machine). steam-deploy itself is loaded exactly like orchestrator - via PluginLoader.load('@game-ci/steam-deploy'), never a static import - matching the app/plugin boundary fixed in #121, not repeating that mistake for a second plugin. Verification: - tsc --noEmit: 737 errors, matching baseline exactly (confirmed via git stash -u comparison, correctly including the new untracked plugin directory in the baseline). - plugins/steam-deploy's own tsc --noEmit: clean. - bun test ./src: 202 pass, 0 fail (was 199 before this commit), including a new integration test confirming steam-deploy loads via PluginLoader and `deploy steam` resolves without engine detection. - plugins/steam-deploy's own vitest: 9/9 pass (VDF generation, SteamCMD output-parsing heuristic - both the pure, most reusable, most valuable logic from the original script). - bun run build: succeeds. - Real functional smoke test end-to-end (see above). - oxfmt --check: clean.
1 parent 20059c2 commit 6fd266c

16 files changed

Lines changed: 661 additions & 45 deletions

bun.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
},
2525
"dependencies": {
2626
"@game-ci/orchestrator": "workspace:*",
27+
"@game-ci/steam-deploy": "workspace:*",
2728
"dotenv": "^16.3.1",
2829
"semver": "^7.5.4",
2930
"yaml": "^2.3.4",

plugins/steam-deploy/package.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "@game-ci/steam-deploy",
3+
"version": "0.1.0",
4+
"description": "Deploy a pre-built game output to Steam via SteamCMD. Engine-agnostic - works on any built output folder, regardless of what built it.",
5+
"license": "MIT",
6+
"repository": "git@github.com:game-ci/cli.git",
7+
"files": [
8+
"dist"
9+
],
10+
"main": "dist/index.js",
11+
"types": "dist/index.d.ts",
12+
"exports": {
13+
".": {
14+
"bun": "./src/index.ts",
15+
"types": "./dist/index.d.ts",
16+
"default": "./dist/index.js"
17+
}
18+
},
19+
"scripts": {
20+
"build": "tsc",
21+
"test": "vitest run",
22+
"test:watch": "vitest",
23+
"typecheck": "tsc --noEmit"
24+
},
25+
"devDependencies": {
26+
"@types/node": "^17.0.23",
27+
"typescript": "4.7.4",
28+
"vitest": "^4"
29+
},
30+
"engines": {
31+
"node": ">=18.x"
32+
}
33+
}

plugins/steam-deploy/src/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { SteamDeployCommand } from "./steam-deploy-command";
2+
3+
/**
4+
* Steam deploy plugin - `game-ci deploy steam <buildPath>`.
5+
*
6+
* Engine-agnostic: it deploys a pre-built output folder, so it doesn't
7+
* matter whether Unity, Godot, Unreal, or anything else produced it.
8+
* Registered with engine: '*' (see PluginRegistry.createCommand's wildcard
9+
* handling), not tied to any specific engine's plugin.
10+
*/
11+
export const steamDeployPlugin = {
12+
name: "steam-deploy",
13+
version: "0.1.0",
14+
15+
commands: [
16+
{
17+
engine: "*",
18+
createCommand(command: string, subCommands: string[]) {
19+
if (command === "deploy" && subCommands[0] === "steam") {
20+
return new SteamDeployCommand();
21+
}
22+
return null;
23+
},
24+
},
25+
],
26+
};
27+
28+
export default steamDeployPlugin;
29+
export { SteamDeployCommand } from "./steam-deploy-command";
30+
export { generateAppVdf, generateDepotVdf } from "./vdf-generator";
31+
export { parseSteamCmdOutput } from "./parse-steamcmd-output";
32+
export { SteamCmdRunner } from "./steamcmd-runner";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from "vitest";
2+
import { parseSteamCmdOutput } from "./parse-steamcmd-output";
3+
4+
describe("parseSteamCmdOutput", () => {
5+
it("reports success and captures the BuildID when the output explicitly confirms success", () => {
6+
const output = "Uploading content...\nSuccessfully finished AppID 123 build (BuildID 45678910).\n";
7+
8+
const result = parseSteamCmdOutput(output, 0);
9+
10+
expect(result.success).toBe(true);
11+
expect(result.buildId).toBe("45678910");
12+
});
13+
14+
it("treats exit code 0 with no explicit confirmation and no error markers as success", () => {
15+
// Real SteamCMD behavior: it doesn't always print the confirmation line even on a genuine success.
16+
const output = "Uploading content...\ndone.\n";
17+
18+
const result = parseSteamCmdOutput(output, 0);
19+
20+
expect(result.success).toBe(true);
21+
expect(result.buildId).toBe("");
22+
});
23+
24+
it("reports failure with a specific reason when the Steam connection dropped", () => {
25+
const output = "Logging in...\ndisconnected from steam\n";
26+
27+
const result = parseSteamCmdOutput(output, 1);
28+
29+
expect(result.success).toBe(false);
30+
expect(result.failureReason).toContain("connection dropped");
31+
});
32+
33+
it("reports failure with a specific reason when the depot build itself failed, even at exit code 0", () => {
34+
// Real SteamCMD behavior: a depot build failure can still exit 0 - the output text is the
35+
// only reliable signal, which is exactly why exit code alone isn't trusted here.
36+
const output = "ERROR! Build for depot 1000 failed.\n";
37+
38+
const result = parseSteamCmdOutput(output, 0);
39+
40+
expect(result.success).toBe(false);
41+
expect(result.failureReason).toContain("depot build reported failure");
42+
});
43+
44+
it("reports failure with a specific reason when the missing-chunks list failed", () => {
45+
const output = "ERROR! Failed to get list of missing chunks.\n";
46+
47+
const result = parseSteamCmdOutput(output, 1);
48+
49+
expect(result.success).toBe(false);
50+
expect(result.failureReason).toContain("missing chunks");
51+
});
52+
53+
it("falls back to the raw exit code when no known failure signature is present", () => {
54+
const output = "Something unexpected happened.\n";
55+
56+
const result = parseSteamCmdOutput(output, 7);
57+
58+
expect(result.success).toBe(false);
59+
expect(result.failureReason).toBe("exit code 7");
60+
});
61+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* SteamCMD's exit code alone is not a reliable success/failure signal - a
3+
* dropped Steam connection or a depot build failure can still exit 0, and a
4+
* genuinely successful upload can occasionally exit non-zero after the
5+
* upload itself completed. This heuristic (ported from a real, production
6+
* PowerShell deploy script) reads the actual output text instead, in this
7+
* priority order:
8+
*
9+
* 1. "Successfully finished" in the output - definitive success.
10+
* 2. Exit code 0 with no explicit error markers - treated as success
11+
* (SteamCMD doesn't always print the confirmation line even on a real
12+
* success).
13+
* 3. Otherwise: failure, with a specific reason extracted from known
14+
* SteamCMD failure signatures where possible (a dropped connection and
15+
* a missing-chunks list both mean the upload can usually just be
16+
* retried, whereas a depot build failure means the content itself was
17+
* rejected).
18+
*/
19+
20+
export interface SteamCmdParseResult {
21+
success: boolean;
22+
/** The Steam BuildID, if one appears in the output. Empty string if not found, even on success. */
23+
buildId: string;
24+
/** Populated on failure with a specific, actionable reason where the output allows identifying one. */
25+
failureReason?: string;
26+
}
27+
28+
export function parseSteamCmdOutput(output: string, exitCode: number): SteamCmdParseResult {
29+
const buildIdMatch = /BuildID\s+(\d+)/.exec(output);
30+
const buildId = buildIdMatch ? buildIdMatch[1] : "";
31+
32+
const successConfirmed = /Successfully finished/.test(output);
33+
const disconnected = /disconnected from steam/i.test(output);
34+
const errorBuild = /ERROR!.*Build for depot.*failed/.test(output);
35+
const errorChunks = /ERROR!.*Failed to get list of missing chunks/.test(output);
36+
37+
if (successConfirmed) {
38+
return { success: true, buildId };
39+
}
40+
41+
if (exitCode === 0 && !errorBuild && !errorChunks) {
42+
return { success: true, buildId };
43+
}
44+
45+
const reasons: string[] = [];
46+
if (disconnected) reasons.push("Steam connection dropped (request revoked)");
47+
if (errorChunks) reasons.push("failed to get list of missing chunks (lost connection)");
48+
if (errorBuild) reasons.push("depot build reported failure");
49+
if (reasons.length === 0) reasons.push(`exit code ${exitCode}`);
50+
51+
return { success: false, buildId: "", failureReason: reasons.join("; ") };
52+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import * as fs from "node:fs";
2+
import * as path from "node:path";
3+
import { generateAppVdf, generateDepotVdf } from "./vdf-generator";
4+
import { SteamCmdRunner } from "./steamcmd-runner";
5+
6+
export interface SteamDeployOptions {
7+
buildPath?: string;
8+
appId?: string;
9+
depotId?: string;
10+
branch?: string;
11+
description?: string;
12+
mode?: string;
13+
steamCmdPath?: string;
14+
steamConfigDir?: string;
15+
extraExclusions?: string;
16+
[key: string]: unknown;
17+
}
18+
19+
interface YargsLike {
20+
option: (name: string, config: Record<string, unknown>) => YargsLike;
21+
}
22+
23+
export class SteamDeployCommand {
24+
public readonly name = "Deploy steam";
25+
26+
public async configureOptions(yargs: YargsLike): Promise<void> {
27+
yargs
28+
.option("appId", {
29+
describe: "Steam App ID",
30+
type: "string",
31+
demandOption: true,
32+
})
33+
.option("depotId", {
34+
describe: "Steam Depot ID",
35+
type: "string",
36+
demandOption: true,
37+
})
38+
.option("branch", {
39+
describe: 'Steam branch to publish to (SteamCMD\'s "setlive" field)',
40+
type: "string",
41+
default: "default",
42+
})
43+
.option("description", {
44+
describe:
45+
"Build description shown in the Steam build history. Defaults to the branch name and current timestamp.",
46+
type: "string",
47+
})
48+
.option("mode", {
49+
describe: "How to run steamcmd: auto (default), local, or docker",
50+
type: "string",
51+
default: "auto",
52+
})
53+
.option("steamCmdPath", {
54+
describe: "Explicit path to the steamcmd executable. Recommended for CI determinism; skips auto-detection.",
55+
type: "string",
56+
})
57+
.option("steamConfigDir", {
58+
describe:
59+
"Host directory containing Steam's config.vdf, mounted into the container in docker mode so login sessions persist.",
60+
type: "string",
61+
})
62+
.option("extraExclusions", {
63+
describe:
64+
"Comma-separated extra file-exclusion glob patterns for the depot, beyond the built-in defaults (*.pdb, *.log, *.vdf, Burst debug/backup folders).",
65+
type: "string",
66+
});
67+
}
68+
69+
public async execute(options: SteamDeployOptions): Promise<boolean> {
70+
const buildPath = options.buildPath;
71+
if (!buildPath) {
72+
throw new Error("A build path is required: game-ci deploy steam <buildPath>");
73+
}
74+
if (!fs.existsSync(buildPath)) {
75+
throw new Error(`Build path does not exist: ${buildPath}`);
76+
}
77+
78+
const username = process.env.STEAM_USERNAME;
79+
const password = process.env.STEAM_PASSWORD;
80+
if (!username || !password) {
81+
throw new Error(
82+
"STEAM_USERNAME and STEAM_PASSWORD must be set as environment variables (never as CLI arguments - argv can leak through process listings).",
83+
);
84+
}
85+
86+
const appId = options.appId!;
87+
const depotId = options.depotId!;
88+
const branch = options.branch ?? "default";
89+
const description = options.description ?? `${branch} build ${new Date().toISOString()}`;
90+
const mode = (options.mode ?? "auto") as "auto" | "local" | "docker";
91+
const extraExclusions = options.extraExclusions
92+
? options.extraExclusions.split(",").map((s) => s.trim())
93+
: undefined;
94+
95+
const absoluteBuildPath = path.resolve(buildPath);
96+
const contentRoot = mode === "docker" ? "/build" : absoluteBuildPath.replace(/\\/g, "/");
97+
const depotFileName = `depot_build_${depotId}.vdf`;
98+
99+
const depotVdf = generateDepotVdf({ depotId, extraExclusions });
100+
const appVdf = generateAppVdf({ appId, depotId, branch, description, depotVdfFileName: depotFileName })
101+
// generateAppVdf's contentroot/buildoutput default to "./" - override to
102+
// the path the running steamcmd process will actually see (an absolute
103+
// host path for local mode, or the container mount point for docker).
104+
.replace('"contentroot" "./"', `"contentroot" "${contentRoot}"`)
105+
.replace('"buildoutput" "./"', `"buildoutput" "${contentRoot}"`);
106+
107+
fs.writeFileSync(path.join(absoluteBuildPath, depotFileName), depotVdf, "utf8");
108+
fs.writeFileSync(path.join(absoluteBuildPath, "manifest.vdf"), appVdf, "utf8");
109+
110+
console.log(`Deploying ${absoluteBuildPath} to Steam app ${appId}, depot ${depotId}, branch "${branch}"`);
111+
112+
const runner = new SteamCmdRunner();
113+
const result = await runner.run({
114+
buildDir: absoluteBuildPath,
115+
username,
116+
password,
117+
mode,
118+
steamCmdPath: options.steamCmdPath,
119+
steamConfigDir: options.steamConfigDir,
120+
});
121+
122+
if (!result.success) {
123+
throw new Error(`Steam deployment failed: ${result.failureReason}`);
124+
}
125+
126+
if (result.buildId) {
127+
console.log(`Steam deployment succeeded. BuildID: ${result.buildId}`);
128+
} else {
129+
console.log("Steam deployment succeeded (no BuildID found in output).");
130+
}
131+
132+
return true;
133+
}
134+
}

0 commit comments

Comments
 (0)