semantic-release-steam 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,14 +8,15 @@ Originally extracted from the [RimworldCosmere](https://github.com/RimworldCosme
8
8
 
9
9
  On `verifyConditions`:
10
10
  - Validates that the current branch maps to a configured workshop target (e.g. `main` -> `stable`, `beta` -> `beta`)
11
- - Checks that `STEAM_USERNAME`, `STEAM_CONFIG_VDF`, and `appId` are present
12
- - Verifies that at least one configured mod has a workshop ID for the resolved target
11
+ - Checks that `STEAM_USERNAME`, a Steam `config.vdf` source (`STEAM_CONFIG_VDF` path or `STEAM_CONFIG_VDF_B64` contents), and `appId` are present
12
+ - Throws clearly if a mod has no `workshopIds`, if a target key looks misspelled (with a "did you mean" suggestion), or if no mod is publishable for the resolved target
13
13
 
14
14
  On `publish`:
15
- - Compiles a Steam Workshop description from each mod's `README.md` (markdown -> BBCode via [steamdown](https://github.com/steamdown/steamdown))
15
+ - Compiles a Steam Workshop description from each mod's `README.template.md` (markdown -> BBCode via [`@steamdown/core`](https://www.npmjs.com/package/@steamdown/core))
16
16
  - Stages mod content into a temp dir, respecting `.steamignore`
17
- - Generates a `workshop.vdf` with `appid`, `publishedfileid`, `contentfolder`, `description`, and `changenote`
17
+ - Generates a `workshop.vdf` with `appid`, `publishedfileid`, `contentfolder`, `description`, `changenote`, plus optional `title`, `previewfile`, `visibility`, and `tags`
18
18
  - Invokes `steamcmd +login $STEAM_USERNAME +workshop_build_item ... +quit`
19
+ - Honors `semantic-release --dry-run`: logs the intended action without invoking SteamCMD
19
20
 
20
21
  The plugin assumes you have already created the workshop item once manually via SteamCMD. It updates existing workshop items by their `publishedfileid` - it does NOT create them. (See "First publish" below.)
21
22
 
@@ -26,38 +27,48 @@ npm install -D semantic-release-steam
26
27
  ```
27
28
 
28
29
  You'll also need:
29
- - [SteamCMD](https://developer.valvesoftware.com/wiki/SteamCMD) on `PATH` (or set `STEAMCMD_PATH`)
30
- - [steamdown](https://www.npmjs.com/package/@steamdown/cli) installed globally for the markdown -> BBCode conversion: `npm install -g @steamdown/cli`
31
- - A pre-authenticated `config.vdf` from SteamCMD (login once interactively, then base64-encode the resulting file into `STEAM_CONFIG_VDF_B64` for CI)
30
+ - [SteamCMD](https://developer.valvesoftware.com/wiki/SteamCMD) on `PATH` (or set `STEAMCMD_PATH`). On GitHub Actions, [`buildalon/setup-steamcmd@v1`](https://github.com/marketplace/actions/setup-steamcmd) handles this.
31
+ - `rsync` (preinstalled on `ubuntu-latest` runners and macOS).
32
+ - A pre-authenticated `config.vdf` from SteamCMD (login once interactively, then base64-encode the resulting file).
33
+
34
+ > [!NOTE]
35
+ > As of v2, the markdown-to-BBCode conversion is bundled as a library dependency. You no longer need to `npm install -g @steamdown/cli`.
32
36
 
33
37
  ## Configuration
34
38
 
35
- In `.releaserc.json` / `release.config.mjs`:
39
+ In `release.config.mjs`:
36
40
 
37
41
  ```js
38
42
  export default {
39
- branches: ["main", { name: "beta", prerelease: true }],
43
+ branches: ['main', { name: 'beta', prerelease: true }],
40
44
  plugins: [
41
- "@semantic-release/commit-analyzer",
42
- "@semantic-release/release-notes-generator",
45
+ '@semantic-release/commit-analyzer',
46
+ '@semantic-release/release-notes-generator',
43
47
  [
44
- "semantic-release-steam",
48
+ 'semantic-release-steam',
45
49
  {
46
- appId: "294100",
47
- branchTargets: { main: "stable", beta: "beta" },
48
- descriptionHeader: "## My Mod\n\n",
49
- descriptionFooter: "\n\n---\n\nReport issues on GitHub.",
50
- assetBaseUrlTemplate: "https://raw.githubusercontent.com/me/my-mod/{branch}",
50
+ appId: '294100',
51
+ branchTargets: { main: 'stable', beta: 'beta' },
52
+ descriptionHeader: '## My Mod\n\n',
53
+ descriptionFooter: '\n\n---\n\nReport issues on GitHub.',
54
+ assetBaseUrlTemplate: 'https://raw.githubusercontent.com/me/my-mod/{branch}',
55
+ uploadTimeoutMs: 600000,
51
56
  mods: [
52
57
  {
53
- name: "MyMod",
54
- path: "MyMod",
55
- workshopIds: { stable: "1234567890", beta: "1234567891" }
56
- }
57
- ]
58
- }
59
- ]
60
- ]
58
+ name: 'MyMod',
59
+ path: 'MyMod',
60
+ workshopIds: { stable: '1234567890', beta: '1234567891' },
61
+ title: 'My Mod',
62
+ tags: ['QoL', 'Utility'],
63
+ visibility: 0,
64
+ metadata: {
65
+ beta: { title: '[BETA] My Mod' },
66
+ },
67
+ },
68
+ ],
69
+ },
70
+ ],
71
+ ],
61
72
  };
62
73
  ```
63
74
 
@@ -66,21 +77,108 @@ export default {
66
77
  | Option | Required | Description |
67
78
  |---|---|---|
68
79
  | `appId` | Yes | Steam app ID (e.g. `294100` for RimWorld). |
69
- | `branchTargets` | Yes | Map of git branch name to workshop visibility tag. Branches not in the map are skipped. |
70
- | `mods` | Yes | Array of `{ name, path, workshopIds }`. `workshopIds` is keyed by the values in `branchTargets`. |
80
+ | `branchTargets` | Yes | Map of git branch name to workshop target key. Branches not in the map are skipped (silently). |
81
+ | `mods` | Yes | Array of [mod config](#mod-config). |
71
82
  | `descriptionHeader` | No | String prepended to each mod's compiled README before BBCode conversion. |
72
83
  | `descriptionFooter` | No | String appended to each mod's compiled README. |
73
- | `assetBaseUrlTemplate` | No | A URL template with `{branch}` placeholder. Asset paths matching `../.github/assets/` in the README are rewritten to absolute URLs against this base before BBCode conversion (so workshop pages don't 404 on relative asset references). |
74
- | `assetDirNameTransform` | No | Function `(modPath) => string[]` returning an ordered list of asset subdirectory names to try inside `<repoRoot>/.github/assets/<dir>/` for fallback substitution. Default: `[basename(modPath).toLowerCase(), 'fallback']`. |
84
+ | `assetBaseUrlTemplate` | No | URL template with `{branch}` placeholder. Asset paths matching `../.github/assets/` in the README are rewritten to absolute URLs against this base. |
85
+ | `assetDirNameTransform` | No | Either an array of subdirectory names under `.github/assets/` (literals or glob patterns), or a function `(modPath) => string[]` returning the ordered list. Default: `[basename(modPath).toLowerCase(), 'fallback']`. |
86
+ | `outputReadme` | No | If `true`, also write the compiled markdown to `<modPath>/README.md` (v1 behavior). Default `false` — the compiled README stays in memory. |
87
+ | `uploadTimeoutMs` | No | Max time in ms to wait for SteamCMD. Default `600000` (10 min). |
88
+ | `verbose` | No | Log SteamCMD stdout/stderr at info level. Default `false` (debug level). |
89
+
90
+ ### Mod config
91
+
92
+ | Field | Required | Description |
93
+ |---|---|---|
94
+ | `name` | Yes | Human-readable name, used in logs. |
95
+ | `path` | Yes | Mod content directory, relative to the semantic-release cwd. |
96
+ | `workshopIds` | Yes | Map of branch-target key (from `branchTargets`) to Steam Workshop `publishedfileid`. |
97
+ | `title` | No | Workshop item title. Only written into the VDF if set. |
98
+ | `previewfile` | No | Path to a preview image (jpg/png). Only written if set. |
99
+ | `visibility` | No | `0` = public, `1` = friends-only, `2` = private, `3` = unlisted. |
100
+ | `tags` | No | Workshop tag array. |
101
+ | `metadata` | No | Per-target overrides. Keys must match values from `branchTargets`. Per-target fields win over mod-level defaults. |
102
+
103
+ Workshop fields (`title`, `previewfile`, `visibility`, `tags`) are only emitted into the VDF when set, so manual edits made in the Steam workshop UI are preserved across publishes for fields you don't manage in config.
75
104
 
76
105
  ### Required environment variables
77
106
 
78
107
  | Var | Purpose |
79
108
  |---|---|
80
- | `STEAM_USERNAME` | Steam account username with workshop publish permission for the configured `appId`. |
81
- | `STEAM_CONFIG_VDF` | Path to a pre-authenticated `config.vdf` (the file SteamCMD writes after a successful interactive login). For CI, decode `STEAM_CONFIG_VDF_B64` into a temp file and pass its path. |
109
+ | `STEAM_USERNAME` | Steam account username with workshop publish permission. |
110
+ | `STEAM_CONFIG_VDF` | Path to a pre-authenticated `config.vdf`. |
111
+ | `STEAM_CONFIG_VDF_B64` | Alternative to `STEAM_CONFIG_VDF`: base64-encoded contents of `config.vdf`. The plugin decodes it to a temp file. |
82
112
  | `STEAMCMD_PATH` | Optional. Path to `steamcmd.sh` / `steamcmd.exe`. Defaults to `~/steamcmd/steamcmd.sh`. |
83
113
 
114
+ At least one of `STEAM_CONFIG_VDF` or `STEAM_CONFIG_VDF_B64` must be set. If both are set, the path wins.
115
+
116
+ > [!IMPORTANT]
117
+ > SteamCMD reads its auth from `$STEAM_DIR/config/config.vdf` (the install dir's config directory). Setting `STEAM_CONFIG_VDF` only tells the plugin where to find the file; you still need to put a copy at the path SteamCMD expects. See the GitHub Actions example below for the standard pattern.
118
+
119
+ ## GitHub Actions example
120
+
121
+ A copy-pasteable workflow that runs semantic-release with the Steam plugin:
122
+
123
+ ```yaml
124
+ name: release
125
+
126
+ on:
127
+ push:
128
+ branches: [main, beta]
129
+
130
+ permissions:
131
+ contents: write
132
+ issues: write
133
+ pull-requests: write
134
+ id-token: write
135
+
136
+ jobs:
137
+ release:
138
+ runs-on: ubuntu-latest
139
+ steps:
140
+ - uses: actions/checkout@v4
141
+ with:
142
+ fetch-depth: 0
143
+ persist-credentials: false
144
+
145
+ - uses: actions/setup-node@v4
146
+ with:
147
+ node-version: '22'
148
+
149
+ - name: Install
150
+ run: npm ci
151
+
152
+ - name: Set up SteamCMD
153
+ uses: buildalon/setup-steamcmd@v1
154
+
155
+ - name: Restore Steam config
156
+ env:
157
+ STEAM_CONFIG_VDF_B64: ${{ secrets.STEAM_CONFIG_VDF_B64 }}
158
+ run: |
159
+ mkdir -p "$STEAM_DIR/config"
160
+ printf '%s' "$STEAM_CONFIG_VDF_B64" | base64 -d > "$STEAM_DIR/config/config.vdf"
161
+ echo "STEAM_CONFIG_VDF=$STEAM_DIR/config/config.vdf" >> "$GITHUB_ENV"
162
+
163
+ - name: Steam login preflight
164
+ env:
165
+ STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }}
166
+ run: |
167
+ "$STEAM_CMD/steamcmd.sh" +@ShutdownOnFailedCommand 1 +@NoPromptForPassword 1 +login "$STEAM_USERNAME" +quit
168
+
169
+ - name: Run semantic-release
170
+ env:
171
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
172
+ STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }}
173
+ STEAM_CONFIG_VDF: ${{ env.STEAM_CONFIG_VDF }}
174
+ STEAMCMD_PATH: ${{ env.STEAM_CMD }}/steamcmd.sh
175
+ run: npx semantic-release
176
+ ```
177
+
178
+ The two secrets you need:
179
+ - `STEAM_USERNAME` — your Steam account username
180
+ - `STEAM_CONFIG_VDF_B64` — `base64 -w 0 ~/Steam/config/config.vdf` from a machine where you've successfully logged in with `steamcmd +login <user> +quit`
181
+
84
182
  ## First publish
85
183
 
86
184
  semantic-release-steam UPDATES existing workshop items - it does not create them. Before the plugin can run, each mod needs a `publishedfileid` per branch target.
@@ -92,19 +190,73 @@ For each branch target (e.g. `stable`, `beta`):
92
190
  3. Note the `publishedfileid` printed by SteamCMD.
93
191
  4. Add the ID to your release config under `mods[*].workshopIds.<target>`.
94
192
 
193
+ For RimWorld specifically, the easier path is to publish the mod once via the in-game workshop publisher, then copy the workshop ID into your config.
194
+
95
195
  After that, every push to `main` / `beta` will trigger an update via this plugin.
96
196
 
197
+ ## Example `.steamignore`
198
+
199
+ Place at the root of each mod's `path` to exclude files from the upload. Patterns are passed to `rsync --exclude-from`.
200
+
201
+ ```
202
+ # Source control
203
+ .git
204
+ .gitignore
205
+ .gitattributes
206
+
207
+ # Docs and templates (the workshop description comes from README.template.md, not these)
208
+ README.md
209
+ README.template.md
210
+ *.md
211
+
212
+ # Build inputs (the staged dir should contain only runtime artifacts)
213
+ Source/
214
+ src/
215
+ *.csproj
216
+ *.sln
217
+
218
+ # CI and local dev clutter
219
+ .github/
220
+ .vscode/
221
+ .idea/
222
+ node_modules/
223
+
224
+ # OS junk
225
+ .DS_Store
226
+ Thumbs.db
227
+ ```
228
+
97
229
  ## How the README gets compiled into a workshop description
98
230
 
99
231
  Each mod is expected to have a `README.template.md`. The plugin:
100
232
 
101
- 1. Concatenates `descriptionHeader + zeroWidthSeparator + template + zeroWidthSeparator + descriptionFooter` into `README.md` at the mod root.
102
- 2. Runs an asset fallback substitution: any `../.github/assets/fallback/<name>` reference is replaced with `../.github/assets/<modspecificdir>/<name>` if a matching file exists, where `<modspecificdir>` comes from `assetDirNameTransform(modPath)`.
233
+ 1. Concatenates `descriptionHeader + zeroWidthSeparator + template + zeroWidthSeparator + descriptionFooter`.
234
+ 2. Runs an asset fallback substitution: any `../.github/assets/fallback/<name>` reference is replaced with `../.github/assets/<modspecificdir>/<name>` if a matching file exists. `<modspecificdir>` is resolved from `assetDirNameTransform`.
103
235
  3. Rewrites `../.github/assets/` paths to absolute URLs using `assetBaseUrlTemplate` (so Steam's BBCode renderer can fetch them).
104
- 4. Pipes the result through `steamdown` to convert Markdown to Steam BBCode.
236
+ 4. Pipes the result through `@steamdown/core` to convert Markdown to Steam BBCode.
105
237
  5. Writes the BBCode into the workshop item description field.
106
238
 
107
- If a mod has no `README.md`, the description defaults to `"No description available."`.
239
+ If a mod has no `README.template.md` (and `outputReadme` is off, leaving no `README.md` to fall back on), the description defaults to `"No description available."`.
240
+
241
+ By default the compiled markdown stays in memory and never touches your repo. Set `outputReadme: true` if you want the compiled `README.md` written to `<modPath>/README.md` (e.g. so `@semantic-release/git` can commit it).
242
+
243
+ ## Upgrading from 1.x
244
+
245
+ Breaking changes in 2.0:
246
+
247
+ - **`@steamdown/cli` is no longer required.** The plugin now uses `@steamdown/core` directly. Remove `npm install -g @steamdown/cli` from your CI.
248
+ - **`README.md` is no longer written into each mod dir by default.** If you relied on this (e.g. committing it via `@semantic-release/git`), set `outputReadme: true`.
249
+ - **Misconfigurations now throw instead of silently skipping.** Specifically: a mod with empty `workshopIds`, a mod missing the target key when no other mod has it either, or a misspelled target key (caught via did-you-mean).
250
+ - **Default upload timeout raised from 2 to 10 minutes.** Set `uploadTimeoutMs` to override.
251
+
252
+ New in 2.0:
253
+
254
+ - `STEAM_CONFIG_VDF_B64` env var accepted alongside `STEAM_CONFIG_VDF`.
255
+ - Optional workshop metadata: `title`, `previewfile`, `visibility`, `tags`. Per-mod defaults with per-target overrides via `mod.metadata[target]`.
256
+ - `assetDirNameTransform` accepts an array (literal or glob) in addition to a function.
257
+ - `semantic-release --dry-run` is honored.
258
+ - `verbose` option to surface SteamCMD output at info level.
259
+ - TypeScript declaration file shipped (`index.d.ts`) and JSON schema (`schema/plugin-config.json`).
108
260
 
109
261
  ## License
110
262
 
package/index.d.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * semantic-release plugin that publishes Steam Workshop items via SteamCMD.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ /** Steam Workshop visibility flag (matches workshop_build_item VDF spec). */
8
+ export type WorkshopVisibility = 0 | 1 | 2 | 3;
9
+
10
+ /** Workshop metadata that can be set per-mod or per-target. */
11
+ export interface WorkshopMetadata {
12
+ /** Workshop item title. Only emitted if set. */
13
+ title?: string;
14
+ /** Path to a preview image (jpg/png), relative to the mod path or absolute. Only emitted if set. */
15
+ previewfile?: string;
16
+ /** 0 = public, 1 = friends-only, 2 = private, 3 = unlisted. Only emitted if set. */
17
+ visibility?: WorkshopVisibility;
18
+ /** Workshop tags. Only emitted if set. */
19
+ tags?: string[];
20
+ }
21
+
22
+ export interface ModConfig extends WorkshopMetadata {
23
+ /** Human-readable mod name, used in logs. */
24
+ name: string;
25
+ /** Mod content directory, relative to the semantic-release cwd. */
26
+ path: string;
27
+ /** Map of branch-target name -> Steam Workshop publishedfileid. Keys must match values from `branchTargets`. */
28
+ workshopIds: Record<string, string>;
29
+ /** Per-target metadata overrides. Values here win over the mod-level defaults. */
30
+ metadata?: Record<string, WorkshopMetadata>;
31
+ }
32
+
33
+ /**
34
+ * Returns an ordered list of subdirectory names under `<repo>/.github/assets/` to try
35
+ * when resolving fallback asset paths. Tried in order until one matches.
36
+ */
37
+ export type AssetDirNameTransform =
38
+ | string[]
39
+ | ((modPath: string) => string[]);
40
+
41
+ export interface PluginConfig {
42
+ /** Steam app ID (e.g. "294100" for RimWorld). */
43
+ appId: string;
44
+ /**
45
+ * Map of git branch name -> workshop target key. Branches not in the map are silently skipped
46
+ * (semantic-release will still run other plugins). The target key is used to look up the
47
+ * `workshopIds` entry for each mod.
48
+ */
49
+ branchTargets: Record<string, string>;
50
+ /** One or more mods to publish. */
51
+ mods: ModConfig[];
52
+
53
+ /** String prepended to each mod's compiled README before BBCode conversion. */
54
+ descriptionHeader?: string;
55
+ /** String appended to each mod's compiled README. */
56
+ descriptionFooter?: string;
57
+ /**
58
+ * URL template with `{branch}` placeholder. Asset paths matching `../.github/assets/`
59
+ * in the README are rewritten to absolute URLs against this base before BBCode conversion.
60
+ */
61
+ assetBaseUrlTemplate?: string;
62
+ /**
63
+ * Resolves the ordered list of asset subdirectory names to try inside `<repo>/.github/assets/`
64
+ * for fallback substitution. Default: `[basename(modPath).toLowerCase(), 'fallback']`.
65
+ *
66
+ * - Function form receives the absolute mod path and returns dir names.
67
+ * - Array form is a static list. Entries containing glob characters (`*?[`) are resolved as
68
+ * globs against `<repo>/.github/assets/`; other entries are used as literal subdirectory names.
69
+ */
70
+ assetDirNameTransform?: AssetDirNameTransform;
71
+
72
+ /**
73
+ * Write the compiled README to `<modPath>/README.md` (the v1 behavior). Default `false`:
74
+ * the compiled markdown is kept in memory and never touches the user's repo.
75
+ *
76
+ * Enable only if you previously relied on the side-effect (e.g. to commit README.md via
77
+ * `@semantic-release/git`).
78
+ */
79
+ outputReadme?: boolean;
80
+ /** Max time in ms to wait for SteamCMD to finish. Default 600000 (10 min). */
81
+ uploadTimeoutMs?: number;
82
+ /**
83
+ * Log SteamCMD stdout/stderr at info level. Default `false` (debug level).
84
+ * Useful for diagnosing CI failures.
85
+ */
86
+ verbose?: boolean;
87
+ }
88
+
89
+ /** Minimal slice of semantic-release's context object that this plugin reads. */
90
+ export interface PluginContext {
91
+ branch: { name: string };
92
+ cwd?: string;
93
+ env: Record<string, string | undefined>;
94
+ nextRelease: { version: string; notes?: string };
95
+ logger: {
96
+ log: (msg: string, ...args: unknown[]) => void;
97
+ error?: (msg: string, ...args: unknown[]) => void;
98
+ debug?: (msg: string, ...args: unknown[]) => void;
99
+ };
100
+ options?: { dryRun?: boolean };
101
+ }
102
+
103
+ export function verifyConditions(pluginConfig: PluginConfig, context: PluginContext): Promise<void>;
104
+ export function publish(pluginConfig: PluginConfig, context: PluginContext): Promise<void>;
105
+
106
+ declare const _default: { verifyConditions: typeof verifyConditions; publish: typeof publish };
107
+ export default _default;
package/index.mjs CHANGED
@@ -1,17 +1,30 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { verifySteamPublishConfig } from './lib/config.mjs';
3
3
  import { buildSteamDescription } from './lib/description.mjs';
4
- import { writeCompiledReadme } from './lib/readme.mjs';
4
+ import { compileReadme, writeCompiledReadme } from './lib/readme.mjs';
5
5
  import { stageModContent } from './lib/stage-content.mjs';
6
6
  import { uploadWorkshopItem } from './lib/steamcmd.mjs';
7
7
 
8
+ function isDryRun(context) {
9
+ return Boolean(context?.options?.dryRun);
10
+ }
11
+
12
+ function resolveModMetadata(mod, target) {
13
+ const override = mod.metadata?.[target] ?? {};
14
+ return {
15
+ title: override.title ?? mod.title,
16
+ previewfile: override.previewfile ?? mod.previewfile,
17
+ visibility: override.visibility ?? mod.visibility,
18
+ tags: override.tags ?? mod.tags,
19
+ };
20
+ }
21
+
8
22
  export async function verifyConditions(pluginConfig, context) {
9
23
  await verifySteamPublishConfig({
10
24
  env: context.env,
11
25
  branchName: context.branch.name,
12
26
  branchTargets: pluginConfig.branchTargets,
13
27
  mods: pluginConfig.mods,
14
- steamConfigPath: context.env.STEAM_CONFIG_VDF,
15
28
  appId: pluginConfig.appId,
16
29
  });
17
30
  }
@@ -22,7 +35,6 @@ export async function publish(pluginConfig, context) {
22
35
  branchName: context.branch.name,
23
36
  branchTargets: pluginConfig.branchTargets,
24
37
  mods: pluginConfig.mods,
25
- steamConfigPath: context.env.STEAM_CONFIG_VDF,
26
38
  appId: pluginConfig.appId,
27
39
  });
28
40
 
@@ -31,41 +43,76 @@ export async function publish(pluginConfig, context) {
31
43
  }
32
44
 
33
45
  const cwd = context.cwd ?? process.cwd();
46
+ const dryRun = isDryRun(context);
34
47
  const buildDescription = context.buildSteamDescription ?? buildSteamDescription;
35
48
  const stageContent = context.stageModContent ?? stageModContent;
36
49
  const uploadItem = context.uploadWorkshopItem ?? uploadWorkshopItem;
37
- const compileReadme = context.writeCompiledReadme ?? writeCompiledReadme;
50
+ const compile = context.compileReadme ?? compileReadme;
51
+ const writeCompiled = context.writeCompiledReadme ?? writeCompiledReadme;
38
52
  const assetBaseUrl = pluginConfig.assetBaseUrlTemplate
39
53
  ? pluginConfig.assetBaseUrlTemplate.replace('{branch}', context.branch.name)
40
54
  : '';
41
55
 
42
56
  for (const mod of state.mods) {
43
57
  const modPath = resolve(cwd, mod.path);
44
- await compileReadme({
58
+
59
+ const compileArgs = {
45
60
  modPath,
46
61
  header: pluginConfig.descriptionHeader ?? '',
47
62
  footer: pluginConfig.descriptionFooter ?? '',
48
63
  assetDirNameTransform: pluginConfig.assetDirNameTransform,
49
- });
50
- const stagePath = await stageContent({ modPath });
64
+ };
65
+
66
+ let markdown;
67
+ if (pluginConfig.outputReadme) {
68
+ markdown = await writeCompiled(compileArgs);
69
+ } else {
70
+ markdown = await compile(compileArgs);
71
+ }
72
+
51
73
  const description = await buildDescription({
52
74
  modPath,
75
+ markdown,
53
76
  assetBaseUrl,
54
77
  });
55
78
 
79
+ const metadata = resolveModMetadata(mod, state.target);
80
+ const publishedFileId = mod.workshopIds[state.target];
81
+ const changenote = context.nextRelease.notes || context.nextRelease.version;
82
+
83
+ if (dryRun) {
84
+ context.logger.log(
85
+ `[dry-run] would publish ${mod.name} to ${state.target} workshop item ${publishedFileId} ` +
86
+ `(changenote: ${context.nextRelease.version}, description: ${description.length} chars` +
87
+ (metadata.title ? `, title: "${metadata.title}"` : '') +
88
+ (metadata.visibility !== undefined ? `, visibility: ${metadata.visibility}` : '') +
89
+ (metadata.tags?.length ? `, tags: [${metadata.tags.join(', ')}]` : '') +
90
+ ')',
91
+ );
92
+ continue;
93
+ }
94
+
95
+ const stagePath = await stageContent({ modPath });
96
+
56
97
  await uploadItem({
57
98
  steamCmdPath: context.env.STEAMCMD_PATH ?? '~/steamcmd/steamcmd.sh',
58
99
  steamUsername: context.env.STEAM_USERNAME,
59
- steamConfigPath: context.env.STEAM_CONFIG_VDF,
100
+ steamConfigPath: state.steamConfigPath,
60
101
  appId: pluginConfig.appId,
61
102
  stagePath,
62
- publishedFileId: mod.workshopIds[state.target],
63
- changenote: context.nextRelease.notes || context.nextRelease.version,
103
+ publishedFileId,
104
+ changenote,
64
105
  description,
106
+ title: metadata.title,
107
+ previewfile: metadata.previewfile,
108
+ visibility: metadata.visibility,
109
+ tags: metadata.tags,
110
+ timeoutMs: pluginConfig.uploadTimeoutMs,
111
+ verbose: pluginConfig.verbose,
65
112
  logger: context.logger,
66
113
  });
67
114
 
68
- context.logger.log(`Published ${mod.name} to ${state.target} workshop item ${mod.workshopIds[state.target]}`);
115
+ context.logger.log(`Published ${mod.name} to ${state.target} workshop item ${publishedFileId}`);
69
116
  }
70
117
 
71
118
  return undefined;
package/lib/config.mjs CHANGED
@@ -1,18 +1,74 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+
1
6
  export function resolveBranchTarget(branchName, branchTargets) {
2
7
  return branchTargets[branchName] ?? null;
3
8
  }
4
9
 
10
+ function levenshtein(a, b) {
11
+ if (a === b) return 0;
12
+ if (!a.length) return b.length;
13
+ if (!b.length) return a.length;
14
+ const prev = new Array(b.length + 1);
15
+ const curr = new Array(b.length + 1);
16
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
17
+ for (let i = 1; i <= a.length; i++) {
18
+ curr[0] = i;
19
+ for (let j = 1; j <= b.length; j++) {
20
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
21
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
22
+ }
23
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
24
+ }
25
+ return prev[b.length];
26
+ }
27
+
28
+ function suggestKey(target, available) {
29
+ if (!available.length) return null;
30
+ const ranked = available
31
+ .map(key => ({ key, distance: levenshtein(target, key) }))
32
+ .sort((a, b) => a.distance - b.distance);
33
+ const best = ranked[0];
34
+ if (best.distance <= Math.max(2, Math.floor(target.length / 3))) {
35
+ return best.key;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Resolves the path to the SteamCMD config.vdf based on env.
42
+ * Accepts either STEAM_CONFIG_VDF (a path) or STEAM_CONFIG_VDF_B64 (base64-encoded contents
43
+ * that get decoded to a temp file). Returns the resolved path.
44
+ */
45
+ export async function resolveSteamConfigPath(env) {
46
+ if (env.STEAM_CONFIG_VDF && env.STEAM_CONFIG_VDF.trim().length > 0) {
47
+ return env.STEAM_CONFIG_VDF;
48
+ }
49
+
50
+ if (env.STEAM_CONFIG_VDF_B64 && env.STEAM_CONFIG_VDF_B64.trim().length > 0) {
51
+ const buffer = Buffer.from(env.STEAM_CONFIG_VDF_B64, 'base64');
52
+ const dir = join(tmpdir(), `semantic-release-steam-${randomBytes(6).toString('hex')}`);
53
+ await mkdir(dir, { recursive: true });
54
+ const path = join(dir, 'config.vdf');
55
+ await writeFile(path, buffer);
56
+ return path;
57
+ }
58
+
59
+ return null;
60
+ }
61
+
5
62
  export async function verifySteamPublishConfig({
6
63
  env,
7
64
  branchName,
8
65
  branchTargets,
9
66
  mods,
10
- steamConfigPath,
11
67
  appId,
12
68
  }) {
13
69
  const target = resolveBranchTarget(branchName, branchTargets);
14
70
  if (!target) {
15
- return { shouldPublish: false, target: null, mods: [] };
71
+ return { shouldPublish: false, target: null, mods: [], steamConfigPath: null };
16
72
  }
17
73
 
18
74
  if (!appId) {
@@ -20,18 +76,61 @@ export async function verifySteamPublishConfig({
20
76
  }
21
77
 
22
78
  if (!env.STEAM_USERNAME) {
23
- throw new Error('STEAM_USERNAME is required for Steam publishing');
79
+ throw new Error(
80
+ 'STEAM_USERNAME is required for Steam publishing (set the env var to your Steam account username)',
81
+ );
24
82
  }
25
83
 
84
+ const steamConfigPath = await resolveSteamConfigPath(env);
26
85
  if (!steamConfigPath) {
27
- throw new Error('Steam config.vdf path is required for Steam publishing');
86
+ throw new Error(
87
+ 'Steam config.vdf path is required for Steam publishing. ' +
88
+ 'Set STEAM_CONFIG_VDF=/path/to/config.vdf, or STEAM_CONFIG_VDF_B64=<base64 of config.vdf contents>.',
89
+ );
28
90
  }
29
91
 
30
- const targetMods = mods.filter(mod => mod.workshopIds?.[target]);
92
+ if (!Array.isArray(mods) || mods.length === 0) {
93
+ throw new Error('mods is required and must contain at least one entry');
94
+ }
95
+
96
+ const publishable = [];
97
+ const skipped = [];
98
+
99
+ for (const mod of mods) {
100
+ const ids = mod.workshopIds ?? {};
101
+ const keys = Object.keys(ids);
102
+
103
+ if (keys.length === 0) {
104
+ throw new Error(
105
+ `Mod "${mod.name}" has no workshopIds set. Each mod must declare at least one workshop ID per branch target.`,
106
+ );
107
+ }
108
+
109
+ if (ids[target]) {
110
+ publishable.push(mod);
111
+ continue;
112
+ }
113
+
114
+ const suggestion = suggestKey(target, keys);
115
+ if (suggestion) {
116
+ throw new Error(
117
+ `Mod "${mod.name}" is missing a workshop ID for target "${target}". ` +
118
+ `Did you mean "${suggestion}"? Found keys: [${keys.join(', ')}].`,
119
+ );
120
+ }
121
+
122
+ skipped.push({ name: mod.name, keys });
123
+ }
31
124
 
32
- if (targetMods.length === 0) {
33
- return { shouldPublish: false, target, mods: [] };
125
+ if (publishable.length === 0) {
126
+ const detail = skipped
127
+ .map(s => `${s.name} (has: [${s.keys.join(', ')}])`)
128
+ .join('; ');
129
+ throw new Error(
130
+ `No mods are publishable for target "${target}". ` +
131
+ `Add a workshopIds["${target}"] entry to at least one mod. Mods checked: ${detail}.`,
132
+ );
34
133
  }
35
134
 
36
- return { shouldPublish: true, target, mods: targetMods };
135
+ return { shouldPublish: true, target, mods: publishable, steamConfigPath };
37
136
  }
@@ -1,43 +1,41 @@
1
1
  import { access, readFile } from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { spawn } from 'node:child_process';
4
+ import { parse, render } from '@steamdown/core';
5
5
  import { rewriteAssetLinksForSteam } from './readme.mjs';
6
6
 
7
- export async function buildSteamDescription({ modPath, assetBaseUrl = '' }) {
8
- const readmePath = join(modPath, 'README.md');
7
+ const FALLBACK_DESCRIPTION = 'No description available.';
9
8
 
10
- try {
11
- await access(readmePath, constants.F_OK);
12
- } catch {
13
- return 'No description available.';
9
+ export function renderSteamBBCode(markdown) {
10
+ if (!markdown || markdown.trim().length === 0) {
11
+ return FALLBACK_DESCRIPTION;
14
12
  }
15
13
 
16
- const markdown = rewriteAssetLinksForSteam(await readFile(readmePath, 'utf8'), assetBaseUrl);
14
+ const [tree, context] = parse(markdown);
15
+ const rendered = render(tree, context);
17
16
 
18
- return await new Promise((resolve, reject) => {
19
- const child = spawn('steamdown', [], { stdio: ['pipe', 'pipe', 'pipe'] });
20
- let stdout = '';
21
- let stderr = '';
17
+ if (!rendered || rendered.trim().length === 0) {
18
+ return FALLBACK_DESCRIPTION;
19
+ }
20
+
21
+ return rendered;
22
+ }
22
23
 
23
- child.stdout.on('data', chunk => {
24
- stdout += chunk;
25
- });
24
+ export async function buildSteamDescription({ modPath, assetBaseUrl = '', markdown }) {
25
+ let source = markdown;
26
26
 
27
- child.stderr.on('data', chunk => {
28
- stderr += chunk;
29
- });
27
+ if (source == null) {
28
+ const readmePath = join(modPath, 'README.md');
30
29
 
31
- child.on('error', reject);
32
- child.on('close', code => {
33
- if (code === 0) {
34
- resolve(stdout || 'No description available.');
35
- return;
36
- }
30
+ try {
31
+ await access(readmePath, constants.F_OK);
32
+ } catch {
33
+ return FALLBACK_DESCRIPTION;
34
+ }
37
35
 
38
- reject(new Error(stderr || `steamdown exited with code ${code}`));
39
- });
36
+ source = await readFile(readmePath, 'utf8');
37
+ }
40
38
 
41
- child.stdin.end(markdown);
42
- });
39
+ const rewritten = rewriteAssetLinksForSteam(source, assetBaseUrl);
40
+ return renderSteamBBCode(rewritten);
43
41
  }
package/lib/readme.mjs CHANGED
@@ -6,10 +6,64 @@ const defaultAssetDirNameTransform = modPath => [
6
6
  'fallback',
7
7
  ];
8
8
 
9
+ const GLOB_CHARS = /[*?[]/;
10
+
11
+ function isGlobPattern(value) {
12
+ return typeof value === 'string' && GLOB_CHARS.test(value);
13
+ }
14
+
15
+ async function listAssetSubdirs(assetsRoot) {
16
+ try {
17
+ const entries = await readdir(assetsRoot, { withFileTypes: true });
18
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ function matchGlob(pattern, candidates) {
25
+ const re = new RegExp(
26
+ '^' +
27
+ pattern
28
+ .replace(/[.+^${}()|\\]/g, '\\$&')
29
+ .replace(/\*\*/g, '[__DOUBLESTAR__]')
30
+ .replace(/\*/g, '[^/]*')
31
+ .replace(/\?/g, '[^/]')
32
+ .replace(/\[__DOUBLESTAR__\]/g, '.*') +
33
+ '$',
34
+ );
35
+ return candidates.filter(c => re.test(c));
36
+ }
37
+
38
+ async function resolveAssetDirs(transform, modPath, repoRoot) {
39
+ if (typeof transform === 'function') {
40
+ return transform(modPath);
41
+ }
42
+
43
+ if (Array.isArray(transform)) {
44
+ const assetsRoot = join(repoRoot, '.github', 'assets');
45
+ const subdirs = transform.some(isGlobPattern) ? await listAssetSubdirs(assetsRoot) : [];
46
+ const resolved = [];
47
+
48
+ for (const entry of transform) {
49
+ if (isGlobPattern(entry)) {
50
+ for (const match of matchGlob(entry, subdirs)) {
51
+ if (!resolved.includes(match)) resolved.push(match);
52
+ }
53
+ } else {
54
+ if (!resolved.includes(entry)) resolved.push(entry);
55
+ }
56
+ }
57
+
58
+ return resolved;
59
+ }
60
+
61
+ return defaultAssetDirNameTransform(modPath);
62
+ }
63
+
9
64
  async function replaceFallbackAssets(markdown, modPath, assetDirNameTransform) {
10
65
  const repoRoot = resolve(modPath, '..');
11
- const transform = assetDirNameTransform ?? defaultAssetDirNameTransform;
12
- const assetDirs = transform(modPath);
66
+ const assetDirs = await resolveAssetDirs(assetDirNameTransform, modPath, repoRoot);
13
67
 
14
68
  for (const assetDir of assetDirs) {
15
69
  const assetPath = join(repoRoot, '.github', 'assets', assetDir);
@@ -42,11 +96,12 @@ async function replaceFallbackAssets(markdown, modPath, assetDirNameTransform) {
42
96
  return markdown;
43
97
  }
44
98
 
45
- const zeroWidthSpace = '\u200B';
99
+ const zeroWidthSpace = '​';
100
+ const stripBom = /^/;
46
101
  const separator = `\n${zeroWidthSpace}\n\n`;
47
102
 
48
103
  export async function compileReadme({ modPath, header, footer, assetDirNameTransform }) {
49
- const template = (await readFile(join(modPath, 'README.template.md'), 'utf8')).replace(/^\uFEFF/, '');
104
+ const template = (await readFile(join(modPath, 'README.template.md'), 'utf8')).replace(stripBom, '');
50
105
  const markdown = `${header}${separator}${template}${separator}${footer}`;
51
106
  return await replaceFallbackAssets(markdown, modPath, assetDirNameTransform);
52
107
  }
package/lib/steamcmd.mjs CHANGED
@@ -7,6 +7,8 @@ import { createWorkshopVdf } from './vdf.mjs';
7
7
 
8
8
  const defaultExecFileAsync = promisify(execFile);
9
9
 
10
+ export const DEFAULT_UPLOAD_TIMEOUT_MS = 600_000;
11
+
10
12
  function expandHomePath(path) {
11
13
  if (!path?.startsWith('~/')) {
12
14
  return path;
@@ -25,6 +27,14 @@ function formatSteamCmdError(error) {
25
27
  return new Error(`${prefix}: ${error.message}${stdout}${stderr}`);
26
28
  }
27
29
 
30
+ function logSteamCmdOutput(logger, verbose, stream, text) {
31
+ if (!logger || !text) return;
32
+ const sink = verbose
33
+ ? (logger.log ?? (() => {}))
34
+ : (logger.debug ?? logger.log ?? (() => {}));
35
+ sink.call(logger, `[steamcmd:${stream}] ${text.trimEnd()}`);
36
+ }
37
+
28
38
  export async function uploadWorkshopItem({
29
39
  steamCmdPath,
30
40
  steamUsername,
@@ -34,6 +44,12 @@ export async function uploadWorkshopItem({
34
44
  changenote,
35
45
  description,
36
46
  appId,
47
+ title,
48
+ previewfile,
49
+ visibility,
50
+ tags,
51
+ timeoutMs = DEFAULT_UPLOAD_TIMEOUT_MS,
52
+ verbose = false,
37
53
  execFileAsync = defaultExecFileAsync,
38
54
  logger,
39
55
  }) {
@@ -48,6 +64,10 @@ export async function uploadWorkshopItem({
48
64
  contentFolder: stagePath,
49
65
  changenote,
50
66
  description,
67
+ title,
68
+ previewfile,
69
+ visibility,
70
+ tags,
51
71
  }));
52
72
 
53
73
  try {
@@ -58,20 +78,15 @@ export async function uploadWorkshopItem({
58
78
  '+workshop_build_item', join(stagePath, 'workshop.vdf'),
59
79
  '+quit',
60
80
  ], {
61
- timeout: 120000,
81
+ timeout: timeoutMs,
62
82
  env: {
63
83
  ...process.env,
64
84
  STEAM_CONFIG_VDF: steamConfigPath,
65
85
  },
66
86
  });
67
87
 
68
- if (logger && result?.stdout) {
69
- logger.log(result.stdout);
70
- }
71
-
72
- if (logger && result?.stderr) {
73
- logger.log(result.stderr);
74
- }
88
+ logSteamCmdOutput(logger, verbose, 'stdout', result?.stdout);
89
+ logSteamCmdOutput(logger, verbose, 'stderr', result?.stderr);
75
90
  } catch (error) {
76
91
  throw formatSteamCmdError(error);
77
92
  }
package/lib/vdf.mjs CHANGED
@@ -2,22 +2,57 @@ function escapeVdfValue(value) {
2
2
  return String(value ?? '').replaceAll('"', '\\"');
3
3
  }
4
4
 
5
+ function appendOptional(lines, key, value) {
6
+ if (value === undefined || value === null || value === '') return;
7
+ lines.push(` "${key}" "${escapeVdfValue(value)}"`);
8
+ }
9
+
10
+ function appendTags(lines, tags) {
11
+ if (!tags || !Array.isArray(tags) || tags.length === 0) return;
12
+ lines.push(' "tags"');
13
+ lines.push(' {');
14
+ for (let i = 0; i < tags.length; i++) {
15
+ lines.push(` "${i}" "${escapeVdfValue(tags[i])}"`);
16
+ }
17
+ lines.push(' }');
18
+ }
19
+
20
+ /**
21
+ * Creates a workshop.vdf for `steamcmd +workshop_build_item`.
22
+ *
23
+ * Required: appId, publishedFileId, contentFolder.
24
+ * Optional and only emitted when set: changenote, description, title, previewfile, visibility, tags.
25
+ */
5
26
  export function createWorkshopVdf({
6
27
  appId,
7
28
  publishedFileId,
8
29
  contentFolder,
9
30
  changenote,
10
31
  description,
32
+ title,
33
+ previewfile,
34
+ visibility,
35
+ tags,
11
36
  }) {
12
- return [
37
+ const lines = [
13
38
  '"workshopitem"',
14
39
  '{',
15
40
  ` "appid" "${escapeVdfValue(appId)}"`,
16
41
  ` "publishedfileid" "${escapeVdfValue(publishedFileId)}"`,
17
42
  ` "contentfolder" "${escapeVdfValue(contentFolder)}"`,
18
- ` "changenote" "${escapeVdfValue(changenote)}"`,
19
- ` "description" "${escapeVdfValue(description)}"`,
20
- '}',
21
- '',
22
- ].join('\n');
43
+ ];
44
+
45
+ appendOptional(lines, 'changenote', changenote);
46
+ appendOptional(lines, 'description', description);
47
+ appendOptional(lines, 'title', title);
48
+ appendOptional(lines, 'previewfile', previewfile);
49
+ if (visibility !== undefined && visibility !== null) {
50
+ appendOptional(lines, 'visibility', String(visibility));
51
+ }
52
+ appendTags(lines, tags);
53
+
54
+ lines.push('}');
55
+ lines.push('');
56
+
57
+ return lines.join('\n');
23
58
  }
package/package.json CHANGED
@@ -1,16 +1,23 @@
1
1
  {
2
2
  "name": "semantic-release-steam",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "semantic-release plugin that publishes a Steam Workshop item from a built mod directory.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
7
+ "types": "./index.d.ts",
7
8
  "exports": {
8
- ".": "./index.mjs",
9
- "./lib/*": "./lib/*"
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.mjs"
12
+ },
13
+ "./lib/*": "./lib/*",
14
+ "./schema/plugin-config.json": "./schema/plugin-config.json"
10
15
  },
11
16
  "files": [
12
17
  "index.mjs",
18
+ "index.d.ts",
13
19
  "lib/",
20
+ "schema/",
14
21
  "LICENSE",
15
22
  "README.md"
16
23
  ],
@@ -20,7 +27,9 @@
20
27
  },
21
28
  "scripts": {
22
29
  "test": "node --test tests/*.test.mjs",
23
- "test:check-config": "node tests/run-config-check.mjs"
30
+ "test:check-config": "node tests/run-config-check.mjs",
31
+ "lint": "eslint .",
32
+ "typecheck": "tsc --noEmit"
24
33
  },
25
34
  "keywords": [
26
35
  "semantic-release",
@@ -35,12 +44,12 @@
35
44
  "license": "MIT",
36
45
  "repository": {
37
46
  "type": "git",
38
- "url": "git+https://github.com/CryptikLemur/semantic-release-steam.git"
47
+ "url": "git+https://github.com/cryptiklemur/semantic-release-steam.git"
39
48
  },
40
49
  "bugs": {
41
- "url": "https://github.com/CryptikLemur/semantic-release-steam/issues"
50
+ "url": "https://github.com/cryptiklemur/semantic-release-steam/issues"
42
51
  },
43
- "homepage": "https://github.com/CryptikLemur/semantic-release-steam#readme",
52
+ "homepage": "https://github.com/cryptiklemur/semantic-release-steam#readme",
44
53
  "engines": {
45
54
  "node": ">=20.0.0"
46
55
  },
@@ -52,8 +61,16 @@
52
61
  "optional": false
53
62
  }
54
63
  },
64
+ "dependencies": {
65
+ "@steamdown/core": "^1.0.0-beta.2"
66
+ },
55
67
  "devDependencies": {
68
+ "@eslint/js": "^9.0.0",
69
+ "@types/node": "^20.0.0",
70
+ "eslint": "^9.0.0",
71
+ "globals": "^15.0.0",
56
72
  "semantic-release": "^25.0.0",
57
- "@semantic-release/git": "^10.0.0"
73
+ "@semantic-release/git": "^10.0.0",
74
+ "typescript": "^5.5.0"
58
75
  }
59
76
  }
@@ -0,0 +1,99 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/cryptiklemur/semantic-release-steam/schema/plugin-config.json",
4
+ "title": "semantic-release-steam plugin config",
5
+ "description": "Configuration for the semantic-release-steam plugin.",
6
+ "type": "object",
7
+ "required": ["appId", "branchTargets", "mods"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "appId": {
11
+ "type": "string",
12
+ "description": "Steam app ID (e.g. \"294100\" for RimWorld)."
13
+ },
14
+ "branchTargets": {
15
+ "type": "object",
16
+ "description": "Map of git branch name to workshop target key. Branches not in the map are skipped.",
17
+ "additionalProperties": { "type": "string" }
18
+ },
19
+ "mods": {
20
+ "type": "array",
21
+ "minItems": 1,
22
+ "items": { "$ref": "#/definitions/ModConfig" }
23
+ },
24
+ "descriptionHeader": { "type": "string" },
25
+ "descriptionFooter": { "type": "string" },
26
+ "assetBaseUrlTemplate": {
27
+ "type": "string",
28
+ "description": "URL template with {branch} placeholder for rewriting relative asset paths."
29
+ },
30
+ "assetDirNameTransform": {
31
+ "description": "Either a list of asset subdirectory names (literals or globs) to try under .github/assets/, or a function returning the list. Functions are only valid in .mjs/.cjs configs.",
32
+ "oneOf": [
33
+ {
34
+ "type": "array",
35
+ "items": { "type": "string" }
36
+ }
37
+ ]
38
+ },
39
+ "outputReadme": {
40
+ "type": "boolean",
41
+ "default": false,
42
+ "description": "Write the compiled README to <modPath>/README.md (v1 behavior). Default false."
43
+ },
44
+ "uploadTimeoutMs": {
45
+ "type": "integer",
46
+ "minimum": 1000,
47
+ "default": 600000,
48
+ "description": "Max time in ms to wait for SteamCMD to finish. Default 600000 (10 min)."
49
+ },
50
+ "verbose": {
51
+ "type": "boolean",
52
+ "default": false,
53
+ "description": "Log SteamCMD stdout/stderr at info level."
54
+ }
55
+ },
56
+ "definitions": {
57
+ "WorkshopMetadata": {
58
+ "type": "object",
59
+ "additionalProperties": false,
60
+ "properties": {
61
+ "title": { "type": "string" },
62
+ "previewfile": { "type": "string" },
63
+ "visibility": {
64
+ "type": "integer",
65
+ "enum": [0, 1, 2, 3],
66
+ "description": "0=public, 1=friends-only, 2=private, 3=unlisted"
67
+ },
68
+ "tags": {
69
+ "type": "array",
70
+ "items": { "type": "string" }
71
+ }
72
+ }
73
+ },
74
+ "ModConfig": {
75
+ "type": "object",
76
+ "required": ["name", "path", "workshopIds"],
77
+ "additionalProperties": false,
78
+ "allOf": [{ "$ref": "#/definitions/WorkshopMetadata" }],
79
+ "properties": {
80
+ "name": { "type": "string" },
81
+ "path": { "type": "string" },
82
+ "workshopIds": {
83
+ "type": "object",
84
+ "additionalProperties": { "type": "string" },
85
+ "description": "Map of branch-target name to Steam Workshop publishedfileid."
86
+ },
87
+ "title": { "type": "string" },
88
+ "previewfile": { "type": "string" },
89
+ "visibility": { "type": "integer", "enum": [0, 1, 2, 3] },
90
+ "tags": { "type": "array", "items": { "type": "string" } },
91
+ "metadata": {
92
+ "type": "object",
93
+ "additionalProperties": { "$ref": "#/definitions/WorkshopMetadata" },
94
+ "description": "Per-target metadata overrides. Keys must match values from branchTargets."
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }