semantic-release-steam 1.0.1 → 2.1.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 +191 -35
- package/index.d.ts +107 -0
- package/index.mjs +58 -11
- package/lib/config.mjs +107 -8
- package/lib/description.mjs +26 -28
- package/lib/readme.mjs +59 -4
- package/lib/stage-content.mjs +28 -4
- package/lib/steamcmd.mjs +23 -8
- package/lib/vdf.mjs +41 -6
- package/package.json +22 -5
- package/schema/plugin-config.json +99 -0
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
|
|
12
|
-
-
|
|
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://
|
|
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 `
|
|
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
|
-
-
|
|
31
|
-
- A pre-authenticated `config.vdf` from SteamCMD (login once interactively, then base64-encode the resulting file
|
|
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
|
|
39
|
+
In `release.config.mjs`:
|
|
36
40
|
|
|
37
41
|
```js
|
|
38
42
|
export default {
|
|
39
|
-
branches: [
|
|
43
|
+
branches: ['main', { name: 'beta', prerelease: true }],
|
|
40
44
|
plugins: [
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
'@semantic-release/commit-analyzer',
|
|
46
|
+
'@semantic-release/release-notes-generator',
|
|
43
47
|
[
|
|
44
|
-
|
|
48
|
+
'semantic-release-steam',
|
|
45
49
|
{
|
|
46
|
-
appId:
|
|
47
|
-
branchTargets: { main:
|
|
48
|
-
descriptionHeader:
|
|
49
|
-
descriptionFooter:
|
|
50
|
-
assetBaseUrlTemplate:
|
|
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:
|
|
54
|
-
path:
|
|
55
|
-
workshopIds: { stable:
|
|
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
|
|
70
|
-
| `mods` | Yes | Array of
|
|
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 |
|
|
74
|
-
| `assetDirNameTransform` | No |
|
|
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
|
|
81
|
-
| `STEAM_CONFIG_VDF` | Path to a pre-authenticated `config.vdf
|
|
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,77 @@ 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 use rsync exclude syntax, plus gitignore-style `!` negation to re-include something a broader pattern would exclude. Later patterns win over earlier ones (gitignore semantics), so put negations after the patterns they override.
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
# Keep .run/ even though dotfiles below are excluded
|
|
203
|
+
.*
|
|
204
|
+
!.run/
|
|
205
|
+
|
|
206
|
+
# Source control
|
|
207
|
+
.git
|
|
208
|
+
.gitignore
|
|
209
|
+
.gitattributes
|
|
210
|
+
|
|
211
|
+
# Docs and templates (the workshop description comes from README.template.md, not these)
|
|
212
|
+
README.md
|
|
213
|
+
README.template.md
|
|
214
|
+
*.md
|
|
215
|
+
|
|
216
|
+
# Build inputs (the staged dir should contain only runtime artifacts)
|
|
217
|
+
Source/
|
|
218
|
+
src/
|
|
219
|
+
*.csproj
|
|
220
|
+
*.sln
|
|
221
|
+
|
|
222
|
+
# CI and local dev clutter
|
|
223
|
+
.github/
|
|
224
|
+
.vscode/
|
|
225
|
+
.idea/
|
|
226
|
+
node_modules/
|
|
227
|
+
|
|
228
|
+
# OS junk
|
|
229
|
+
.DS_Store
|
|
230
|
+
Thumbs.db
|
|
231
|
+
```
|
|
232
|
+
|
|
97
233
|
## How the README gets compiled into a workshop description
|
|
98
234
|
|
|
99
235
|
Each mod is expected to have a `README.template.md`. The plugin:
|
|
100
236
|
|
|
101
|
-
1. Concatenates `descriptionHeader + zeroWidthSeparator + template + zeroWidthSeparator + descriptionFooter
|
|
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
|
|
237
|
+
1. Concatenates `descriptionHeader + zeroWidthSeparator + template + zeroWidthSeparator + descriptionFooter`.
|
|
238
|
+
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
239
|
3. Rewrites `../.github/assets/` paths to absolute URLs using `assetBaseUrlTemplate` (so Steam's BBCode renderer can fetch them).
|
|
104
|
-
4. Pipes the result through
|
|
240
|
+
4. Pipes the result through `@steamdown/core` to convert Markdown to Steam BBCode.
|
|
105
241
|
5. Writes the BBCode into the workshop item description field.
|
|
106
242
|
|
|
107
|
-
If a mod has no `README.md
|
|
243
|
+
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."`.
|
|
244
|
+
|
|
245
|
+
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).
|
|
246
|
+
|
|
247
|
+
## Upgrading from 1.x
|
|
248
|
+
|
|
249
|
+
Breaking changes in 2.0:
|
|
250
|
+
|
|
251
|
+
- **`@steamdown/cli` is no longer required.** The plugin now uses `@steamdown/core` directly. Remove `npm install -g @steamdown/cli` from your CI.
|
|
252
|
+
- **`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`.
|
|
253
|
+
- **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).
|
|
254
|
+
- **Default upload timeout raised from 2 to 10 minutes.** Set `uploadTimeoutMs` to override.
|
|
255
|
+
|
|
256
|
+
New in 2.0:
|
|
257
|
+
|
|
258
|
+
- `STEAM_CONFIG_VDF_B64` env var accepted alongside `STEAM_CONFIG_VDF`.
|
|
259
|
+
- Optional workshop metadata: `title`, `previewfile`, `visibility`, `tags`. Per-mod defaults with per-target overrides via `mod.metadata[target]`.
|
|
260
|
+
- `assetDirNameTransform` accepts an array (literal or glob) in addition to a function.
|
|
261
|
+
- `semantic-release --dry-run` is honored.
|
|
262
|
+
- `verbose` option to surface SteamCMD output at info level.
|
|
263
|
+
- TypeScript declaration file shipped (`index.d.ts`) and JSON schema (`schema/plugin-config.json`).
|
|
108
264
|
|
|
109
265
|
## License
|
|
110
266
|
|
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
|
|
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
|
-
|
|
58
|
+
|
|
59
|
+
const compileArgs = {
|
|
45
60
|
modPath,
|
|
46
61
|
header: pluginConfig.descriptionHeader ?? '',
|
|
47
62
|
footer: pluginConfig.descriptionFooter ?? '',
|
|
48
63
|
assetDirNameTransform: pluginConfig.assetDirNameTransform,
|
|
49
|
-
}
|
|
50
|
-
|
|
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:
|
|
100
|
+
steamConfigPath: state.steamConfigPath,
|
|
60
101
|
appId: pluginConfig.appId,
|
|
61
102
|
stagePath,
|
|
62
|
-
publishedFileId
|
|
63
|
-
changenote
|
|
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 ${
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
33
|
-
|
|
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:
|
|
135
|
+
return { shouldPublish: true, target, mods: publishable, steamConfigPath };
|
|
37
136
|
}
|
package/lib/description.mjs
CHANGED
|
@@ -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 {
|
|
4
|
+
import { parse, render } from '@steamdown/core';
|
|
5
5
|
import { rewriteAssetLinksForSteam } from './readme.mjs';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
const readmePath = join(modPath, 'README.md');
|
|
7
|
+
const FALLBACK_DESCRIPTION = 'No description available.';
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
14
|
+
const [tree, context] = parse(markdown);
|
|
15
|
+
const rendered = render(tree, context);
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
if (!rendered || rendered.trim().length === 0) {
|
|
18
|
+
return FALLBACK_DESCRIPTION;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return rendered;
|
|
22
|
+
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
});
|
|
24
|
+
export async function buildSteamDescription({ modPath, assetBaseUrl = '', markdown }) {
|
|
25
|
+
let source = markdown;
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
});
|
|
27
|
+
if (source == null) {
|
|
28
|
+
const readmePath = join(modPath, 'README.md');
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
30
|
+
try {
|
|
31
|
+
await access(readmePath, constants.F_OK);
|
|
32
|
+
} catch {
|
|
33
|
+
return FALLBACK_DESCRIPTION;
|
|
34
|
+
}
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
source = await readFile(readmePath, 'utf8');
|
|
37
|
+
}
|
|
40
38
|
|
|
41
|
-
|
|
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
|
|
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 = '
|
|
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(
|
|
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/stage-content.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { constants } from 'node:fs';
|
|
1
|
+
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
3
2
|
import { tmpdir } from 'node:os';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import { execFile } from 'node:child_process';
|
|
@@ -7,14 +6,39 @@ import { promisify } from 'node:util';
|
|
|
7
6
|
|
|
8
7
|
const execFileAsync = promisify(execFile);
|
|
9
8
|
|
|
9
|
+
function buildFilterArgs(content) {
|
|
10
|
+
const rules = [];
|
|
11
|
+
|
|
12
|
+
for (const raw of content.split('\n')) {
|
|
13
|
+
const line = raw.trim();
|
|
14
|
+
if (!line || line.startsWith('#')) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (line.startsWith('!')) {
|
|
18
|
+
const pattern = line.slice(1).trim();
|
|
19
|
+
if (pattern) {
|
|
20
|
+
rules.push(`+ ${pattern}`);
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
rules.push(`- ${line}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// .steamignore is last-match-wins (gitignore semantics); rsync filters are
|
|
28
|
+
// first-match-wins, so reverse the rule order to preserve precedence.
|
|
29
|
+
rules.reverse();
|
|
30
|
+
|
|
31
|
+
return rules.map((rule) => `--filter=${rule}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
export async function stageModContent({ modPath }) {
|
|
11
35
|
const stagePath = await mkdtemp(join(tmpdir(), 'steam-release-'));
|
|
12
36
|
const ignorePath = join(modPath, '.steamignore');
|
|
13
37
|
const args = ['-av'];
|
|
14
38
|
|
|
15
39
|
try {
|
|
16
|
-
await
|
|
17
|
-
args.push(
|
|
40
|
+
const content = await readFile(ignorePath, 'utf8');
|
|
41
|
+
args.push('--exclude=.steamignore', ...buildFilterArgs(content));
|
|
18
42
|
} catch {
|
|
19
43
|
}
|
|
20
44
|
|
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:
|
|
81
|
+
timeout: timeoutMs,
|
|
62
82
|
env: {
|
|
63
83
|
...process.env,
|
|
64
84
|
STEAM_CONFIG_VDF: steamConfigPath,
|
|
65
85
|
},
|
|
66
86
|
});
|
|
67
87
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
37
|
+
const lines = [
|
|
13
38
|
'"workshopitem"',
|
|
14
39
|
'{',
|
|
15
40
|
` "appid" "${escapeVdfValue(appId)}"`,
|
|
16
41
|
` "publishedfileid" "${escapeVdfValue(publishedFileId)}"`,
|
|
17
42
|
` "contentfolder" "${escapeVdfValue(contentFolder)}"`,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
3
|
+
"version": "2.1.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
|
-
".":
|
|
9
|
-
|
|
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",
|
|
@@ -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
|
+
}
|