semantic-release-steam 1.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/LICENSE +21 -0
- package/README.md +111 -0
- package/index.mjs +74 -0
- package/lib/config.mjs +37 -0
- package/lib/description.mjs +43 -0
- package/lib/readme.mjs +67 -0
- package/lib/stage-content.mjs +25 -0
- package/lib/steamcmd.mjs +80 -0
- package/lib/vdf.mjs +23 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aaron Scherer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# semantic-release-steam
|
|
2
|
+
|
|
3
|
+
A [semantic-release](https://github.com/semantic-release/semantic-release) plugin that publishes a built Steam Workshop item via [SteamCMD](https://developer.valvesoftware.com/wiki/SteamCMD).
|
|
4
|
+
|
|
5
|
+
Originally extracted from the [RimworldCosmere](https://github.com/RimworldCosmere/RimworldCosmere) project's release pipeline. Generic enough to publish any Steam Workshop item for any Steam app, not just RimWorld.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
On `verifyConditions`:
|
|
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
|
|
13
|
+
|
|
14
|
+
On `publish`:
|
|
15
|
+
- Compiles a Steam Workshop description from each mod's `README.md` (markdown -> BBCode via [steamdown](https://github.com/steamdown/steamdown))
|
|
16
|
+
- Stages mod content into a temp dir, respecting `.steamignore`
|
|
17
|
+
- Generates a `workshop.vdf` with `appid`, `publishedfileid`, `contentfolder`, `description`, and `changenote`
|
|
18
|
+
- Invokes `steamcmd +login $STEAM_USERNAME +workshop_build_item ... +quit`
|
|
19
|
+
|
|
20
|
+
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
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -D semantic-release-steam
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
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)
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
In `.releaserc.json` / `release.config.mjs`:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
export default {
|
|
39
|
+
branches: ["main", { name: "beta", prerelease: true }],
|
|
40
|
+
plugins: [
|
|
41
|
+
"@semantic-release/commit-analyzer",
|
|
42
|
+
"@semantic-release/release-notes-generator",
|
|
43
|
+
[
|
|
44
|
+
"semantic-release-steam",
|
|
45
|
+
{
|
|
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}",
|
|
51
|
+
mods: [
|
|
52
|
+
{
|
|
53
|
+
name: "MyMod",
|
|
54
|
+
path: "MyMod",
|
|
55
|
+
workshopIds: { stable: "1234567890", beta: "1234567891" }
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
]
|
|
61
|
+
};
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Plugin options
|
|
65
|
+
|
|
66
|
+
| Option | Required | Description |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `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`. |
|
|
71
|
+
| `descriptionHeader` | No | String prepended to each mod's compiled README before BBCode conversion. |
|
|
72
|
+
| `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']`. |
|
|
75
|
+
|
|
76
|
+
### Required environment variables
|
|
77
|
+
|
|
78
|
+
| Var | Purpose |
|
|
79
|
+
|---|---|
|
|
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. |
|
|
82
|
+
| `STEAMCMD_PATH` | Optional. Path to `steamcmd.sh` / `steamcmd.exe`. Defaults to `~/steamcmd/steamcmd.sh`. |
|
|
83
|
+
|
|
84
|
+
## First publish
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
88
|
+
For each branch target (e.g. `stable`, `beta`):
|
|
89
|
+
|
|
90
|
+
1. Build your mod content locally.
|
|
91
|
+
2. Stage it into a temp dir, generate a `workshopitem` VDF (without `publishedfileid`), and run `steamcmd +login $STEAM_USERNAME +workshop_build_item path/to/workshop.vdf +quit`.
|
|
92
|
+
3. Note the `publishedfileid` printed by SteamCMD.
|
|
93
|
+
4. Add the ID to your release config under `mods[*].workshopIds.<target>`.
|
|
94
|
+
|
|
95
|
+
After that, every push to `main` / `beta` will trigger an update via this plugin.
|
|
96
|
+
|
|
97
|
+
## How the README gets compiled into a workshop description
|
|
98
|
+
|
|
99
|
+
Each mod is expected to have a `README.template.md`. The plugin:
|
|
100
|
+
|
|
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)`.
|
|
103
|
+
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.
|
|
105
|
+
5. Writes the BBCode into the workshop item description field.
|
|
106
|
+
|
|
107
|
+
If a mod has no `README.md`, the description defaults to `"No description available."`.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT - see [LICENSE](./LICENSE).
|
package/index.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { verifySteamPublishConfig } from './lib/config.mjs';
|
|
3
|
+
import { buildSteamDescription } from './lib/description.mjs';
|
|
4
|
+
import { writeCompiledReadme } from './lib/readme.mjs';
|
|
5
|
+
import { stageModContent } from './lib/stage-content.mjs';
|
|
6
|
+
import { uploadWorkshopItem } from './lib/steamcmd.mjs';
|
|
7
|
+
|
|
8
|
+
export async function verifyConditions(pluginConfig, context) {
|
|
9
|
+
await verifySteamPublishConfig({
|
|
10
|
+
env: context.env,
|
|
11
|
+
branchName: context.branch.name,
|
|
12
|
+
branchTargets: pluginConfig.branchTargets,
|
|
13
|
+
mods: pluginConfig.mods,
|
|
14
|
+
steamConfigPath: context.env.STEAM_CONFIG_VDF,
|
|
15
|
+
appId: pluginConfig.appId,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function publish(pluginConfig, context) {
|
|
20
|
+
const state = await verifySteamPublishConfig({
|
|
21
|
+
env: context.env,
|
|
22
|
+
branchName: context.branch.name,
|
|
23
|
+
branchTargets: pluginConfig.branchTargets,
|
|
24
|
+
mods: pluginConfig.mods,
|
|
25
|
+
steamConfigPath: context.env.STEAM_CONFIG_VDF,
|
|
26
|
+
appId: pluginConfig.appId,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!state.shouldPublish) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cwd = context.cwd ?? process.cwd();
|
|
34
|
+
const buildDescription = context.buildSteamDescription ?? buildSteamDescription;
|
|
35
|
+
const stageContent = context.stageModContent ?? stageModContent;
|
|
36
|
+
const uploadItem = context.uploadWorkshopItem ?? uploadWorkshopItem;
|
|
37
|
+
const compileReadme = context.writeCompiledReadme ?? writeCompiledReadme;
|
|
38
|
+
const assetBaseUrl = pluginConfig.assetBaseUrlTemplate
|
|
39
|
+
? pluginConfig.assetBaseUrlTemplate.replace('{branch}', context.branch.name)
|
|
40
|
+
: '';
|
|
41
|
+
|
|
42
|
+
for (const mod of state.mods) {
|
|
43
|
+
const modPath = resolve(cwd, mod.path);
|
|
44
|
+
await compileReadme({
|
|
45
|
+
modPath,
|
|
46
|
+
header: pluginConfig.descriptionHeader ?? '',
|
|
47
|
+
footer: pluginConfig.descriptionFooter ?? '',
|
|
48
|
+
assetDirNameTransform: pluginConfig.assetDirNameTransform,
|
|
49
|
+
});
|
|
50
|
+
const stagePath = await stageContent({ modPath });
|
|
51
|
+
const description = await buildDescription({
|
|
52
|
+
modPath,
|
|
53
|
+
assetBaseUrl,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await uploadItem({
|
|
57
|
+
steamCmdPath: context.env.STEAMCMD_PATH ?? '~/steamcmd/steamcmd.sh',
|
|
58
|
+
steamUsername: context.env.STEAM_USERNAME,
|
|
59
|
+
steamConfigPath: context.env.STEAM_CONFIG_VDF,
|
|
60
|
+
appId: pluginConfig.appId,
|
|
61
|
+
stagePath,
|
|
62
|
+
publishedFileId: mod.workshopIds[state.target],
|
|
63
|
+
changenote: context.nextRelease.notes || context.nextRelease.version,
|
|
64
|
+
description,
|
|
65
|
+
logger: context.logger,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
context.logger.log(`Published ${mod.name} to ${state.target} workshop item ${mod.workshopIds[state.target]}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default { verifyConditions, publish };
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function resolveBranchTarget(branchName, branchTargets) {
|
|
2
|
+
return branchTargets[branchName] ?? null;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export async function verifySteamPublishConfig({
|
|
6
|
+
env,
|
|
7
|
+
branchName,
|
|
8
|
+
branchTargets,
|
|
9
|
+
mods,
|
|
10
|
+
steamConfigPath,
|
|
11
|
+
appId,
|
|
12
|
+
}) {
|
|
13
|
+
const target = resolveBranchTarget(branchName, branchTargets);
|
|
14
|
+
if (!target) {
|
|
15
|
+
return { shouldPublish: false, target: null, mods: [] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!appId) {
|
|
19
|
+
throw new Error('appId is required for Steam publishing (pass it in pluginConfig)');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!env.STEAM_USERNAME) {
|
|
23
|
+
throw new Error('STEAM_USERNAME is required for Steam publishing');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!steamConfigPath) {
|
|
27
|
+
throw new Error('Steam config.vdf path is required for Steam publishing');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const targetMods = mods.filter(mod => mod.workshopIds?.[target]);
|
|
31
|
+
|
|
32
|
+
if (targetMods.length === 0) {
|
|
33
|
+
return { shouldPublish: false, target, mods: [] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { shouldPublish: true, target, mods: targetMods };
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { rewriteAssetLinksForSteam } from './readme.mjs';
|
|
6
|
+
|
|
7
|
+
export async function buildSteamDescription({ modPath, assetBaseUrl = '' }) {
|
|
8
|
+
const readmePath = join(modPath, 'README.md');
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
await access(readmePath, constants.F_OK);
|
|
12
|
+
} catch {
|
|
13
|
+
return 'No description available.';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const markdown = rewriteAssetLinksForSteam(await readFile(readmePath, 'utf8'), assetBaseUrl);
|
|
17
|
+
|
|
18
|
+
return await new Promise((resolve, reject) => {
|
|
19
|
+
const child = spawn('steamdown', [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
20
|
+
let stdout = '';
|
|
21
|
+
let stderr = '';
|
|
22
|
+
|
|
23
|
+
child.stdout.on('data', chunk => {
|
|
24
|
+
stdout += chunk;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
child.stderr.on('data', chunk => {
|
|
28
|
+
stderr += chunk;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
child.on('error', reject);
|
|
32
|
+
child.on('close', code => {
|
|
33
|
+
if (code === 0) {
|
|
34
|
+
resolve(stdout || 'No description available.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
reject(new Error(stderr || `steamdown exited with code ${code}`));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
child.stdin.end(markdown);
|
|
42
|
+
});
|
|
43
|
+
}
|
package/lib/readme.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const defaultAssetDirNameTransform = modPath => [
|
|
5
|
+
basename(modPath).toLowerCase(),
|
|
6
|
+
'fallback',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
async function replaceFallbackAssets(markdown, modPath, assetDirNameTransform) {
|
|
10
|
+
const repoRoot = resolve(modPath, '..');
|
|
11
|
+
const transform = assetDirNameTransform ?? defaultAssetDirNameTransform;
|
|
12
|
+
const assetDirs = transform(modPath);
|
|
13
|
+
|
|
14
|
+
for (const assetDir of assetDirs) {
|
|
15
|
+
const assetPath = join(repoRoot, '.github', 'assets', assetDir);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const entries = await readdir(assetPath);
|
|
19
|
+
let nextMarkdown = markdown;
|
|
20
|
+
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const fallbackNames = new Set([
|
|
23
|
+
entry,
|
|
24
|
+
entry.replaceAll('_', ' '),
|
|
25
|
+
entry.replaceAll(' ', '_'),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
for (const fallbackName of fallbackNames) {
|
|
29
|
+
nextMarkdown = nextMarkdown.replaceAll(
|
|
30
|
+
`../.github/assets/fallback/${fallbackName}`,
|
|
31
|
+
`../.github/assets/${assetDir}/${entry}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
markdown = nextMarkdown;
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return markdown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const zeroWidthSpace = '\u200B';
|
|
46
|
+
const separator = `\n${zeroWidthSpace}\n\n`;
|
|
47
|
+
|
|
48
|
+
export async function compileReadme({ modPath, header, footer, assetDirNameTransform }) {
|
|
49
|
+
const template = (await readFile(join(modPath, 'README.template.md'), 'utf8')).replace(/^\uFEFF/, '');
|
|
50
|
+
const markdown = `${header}${separator}${template}${separator}${footer}`;
|
|
51
|
+
return await replaceFallbackAssets(markdown, modPath, assetDirNameTransform);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function writeCompiledReadme({ modPath, header, footer, assetDirNameTransform }) {
|
|
55
|
+
const markdown = await compileReadme({ modPath, header, footer, assetDirNameTransform });
|
|
56
|
+
await mkdir(modPath, { recursive: true });
|
|
57
|
+
await writeFile(join(modPath, 'README.md'), markdown);
|
|
58
|
+
return markdown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function rewriteAssetLinksForSteam(markdown, assetBaseUrl) {
|
|
62
|
+
if (!assetBaseUrl) {
|
|
63
|
+
return markdown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return markdown.replaceAll('../.github/assets/', `${assetBaseUrl}/.github/assets/`);
|
|
67
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { access, mkdtemp } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export async function stageModContent({ modPath }) {
|
|
11
|
+
const stagePath = await mkdtemp(join(tmpdir(), 'steam-release-'));
|
|
12
|
+
const ignorePath = join(modPath, '.steamignore');
|
|
13
|
+
const args = ['-av'];
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await access(ignorePath, constants.F_OK);
|
|
17
|
+
args.push(`--exclude-from=${ignorePath}`, '--exclude=.steamignore');
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
args.push(`${modPath}/`, stagePath);
|
|
22
|
+
await execFileAsync('rsync', args);
|
|
23
|
+
|
|
24
|
+
return stagePath;
|
|
25
|
+
}
|
package/lib/steamcmd.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createWorkshopVdf } from './vdf.mjs';
|
|
7
|
+
|
|
8
|
+
const defaultExecFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
function expandHomePath(path) {
|
|
11
|
+
if (!path?.startsWith('~/')) {
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return join(homedir(), path.slice(2));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatSteamCmdError(error) {
|
|
19
|
+
const prefix = error?.code === 'ETIMEDOUT'
|
|
20
|
+
? 'SteamCMD upload timed out'
|
|
21
|
+
: 'SteamCMD upload failed';
|
|
22
|
+
const stdout = error?.stdout ? `\nstdout:\n${error.stdout}` : '';
|
|
23
|
+
const stderr = error?.stderr ? `\nstderr:\n${error.stderr}` : '';
|
|
24
|
+
|
|
25
|
+
return new Error(`${prefix}: ${error.message}${stdout}${stderr}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function uploadWorkshopItem({
|
|
29
|
+
steamCmdPath,
|
|
30
|
+
steamUsername,
|
|
31
|
+
steamConfigPath,
|
|
32
|
+
stagePath,
|
|
33
|
+
publishedFileId,
|
|
34
|
+
changenote,
|
|
35
|
+
description,
|
|
36
|
+
appId,
|
|
37
|
+
execFileAsync = defaultExecFileAsync,
|
|
38
|
+
logger,
|
|
39
|
+
}) {
|
|
40
|
+
if (!appId) {
|
|
41
|
+
throw new Error('appId is required for uploadWorkshopItem');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const vdfPath = join(stagePath, 'workshop.vdf');
|
|
45
|
+
await writeFile(vdfPath, createWorkshopVdf({
|
|
46
|
+
appId,
|
|
47
|
+
publishedFileId,
|
|
48
|
+
contentFolder: stagePath,
|
|
49
|
+
changenote,
|
|
50
|
+
description,
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const result = await execFileAsync(expandHomePath(steamCmdPath), [
|
|
55
|
+
'+@ShutdownOnFailedCommand', '1',
|
|
56
|
+
'+@NoPromptForPassword', '1',
|
|
57
|
+
'+login', steamUsername,
|
|
58
|
+
'+workshop_build_item', join(stagePath, 'workshop.vdf'),
|
|
59
|
+
'+quit',
|
|
60
|
+
], {
|
|
61
|
+
timeout: 120000,
|
|
62
|
+
env: {
|
|
63
|
+
...process.env,
|
|
64
|
+
STEAM_CONFIG_VDF: steamConfigPath,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (logger && result?.stdout) {
|
|
69
|
+
logger.log(result.stdout);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (logger && result?.stderr) {
|
|
73
|
+
logger.log(result.stderr);
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw formatSteamCmdError(error);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return vdfPath;
|
|
80
|
+
}
|
package/lib/vdf.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function escapeVdfValue(value) {
|
|
2
|
+
return String(value ?? '').replaceAll('"', '\\"');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function createWorkshopVdf({
|
|
6
|
+
appId,
|
|
7
|
+
publishedFileId,
|
|
8
|
+
contentFolder,
|
|
9
|
+
changenote,
|
|
10
|
+
description,
|
|
11
|
+
}) {
|
|
12
|
+
return [
|
|
13
|
+
'"workshopitem"',
|
|
14
|
+
'{',
|
|
15
|
+
` "appid" "${escapeVdfValue(appId)}"`,
|
|
16
|
+
` "publishedfileid" "${escapeVdfValue(publishedFileId)}"`,
|
|
17
|
+
` "contentfolder" "${escapeVdfValue(contentFolder)}"`,
|
|
18
|
+
` "changenote" "${escapeVdfValue(changenote)}"`,
|
|
19
|
+
` "description" "${escapeVdfValue(description)}"`,
|
|
20
|
+
'}',
|
|
21
|
+
'',
|
|
22
|
+
].join('\n');
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "semantic-release-steam",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "semantic-release plugin that publishes a Steam Workshop item from a built mod directory.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.mjs",
|
|
9
|
+
"./lib/*": "./lib/*"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.mjs",
|
|
13
|
+
"lib/",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"provenance": true
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test tests/*.test.mjs",
|
|
23
|
+
"test:check-config": "node tests/run-config-check.mjs"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"semantic-release",
|
|
27
|
+
"semantic-release-plugin",
|
|
28
|
+
"steam",
|
|
29
|
+
"steam-workshop",
|
|
30
|
+
"steamcmd",
|
|
31
|
+
"release",
|
|
32
|
+
"publish"
|
|
33
|
+
],
|
|
34
|
+
"author": "Aaron Scherer <aequasi@gmail.com>",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/CryptikLemur/semantic-release-steam.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/CryptikLemur/semantic-release-steam/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/CryptikLemur/semantic-release-steam#readme",
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"semantic-release": ">=22.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"semantic-release": {
|
|
52
|
+
"optional": false
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"semantic-release": "^25.0.0",
|
|
57
|
+
"@semantic-release/git": "^10.0.0"
|
|
58
|
+
}
|
|
59
|
+
}
|