semantic-release-steam 2.1.4 → 2.2.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 +119 -0
- package/bin/apply-tags.mjs +89 -0
- package/index.mjs +17 -0
- package/lib/config.mjs +29 -6
- package/lib/steamcmd.mjs +2 -0
- package/lib/workshop-tags.mjs +195 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -43,6 +43,10 @@ Set these variables in your release environment:
|
|
|
43
43
|
- `STEAM_USERNAME`
|
|
44
44
|
- `STEAM_CONFIG_VDF` or `STEAM_CONFIG_VDF_B64`
|
|
45
45
|
- `STEAMCMD_PATH` if SteamCMD isn't at `~/steamcmd/steamcmd.sh`
|
|
46
|
+
- `STEAM_PASSWORD` or `STEAM_REFRESH_TOKEN`, only if any mod sets `tags` - these set Steam
|
|
47
|
+
Workshop browse tags over a separate login, since SteamCMD itself has no way to set them.
|
|
48
|
+
`STEAM_REFRESH_TOKEN` skips the Steam Guard prompt; `STEAM_PASSWORD` needs one approval tap
|
|
49
|
+
per publish.
|
|
46
50
|
|
|
47
51
|
SteamCMD must have a saved login in its `config/config.vdf`. See the [SteamCMD Workshop upload guide](https://partner.steamgames.com/doc/features/workshop/implementation#SteamCmdIntegration) for the item setup and VDF format.
|
|
48
52
|
|
|
@@ -57,6 +61,121 @@ The plugin updates Workshop items. Create each item before your first release, t
|
|
|
57
61
|
- [GitHub Actions setup](https://semantic-release.gitbook.io/semantic-release/recipes/ci-configurations/github-actions)
|
|
58
62
|
- [Steam Workshop implementation guide](https://partner.steamgames.com/doc/features/workshop/implementation)
|
|
59
63
|
|
|
64
|
+
## Recovering a missed tag update
|
|
65
|
+
|
|
66
|
+
If a publish uploads through SteamCMD but fails before the tag step finishes (a missed Steam
|
|
67
|
+
Guard tap, a dropped connection), retag the item without a new release:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx semantic-release-steam-apply-tags --config release.config.mjs.json --target main
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`--config` points at a JSON file holding just this plugin's own options (`appId`,
|
|
74
|
+
`branchTargets`, `mods`), not the full `release.config.mjs`. Write it once, for example with
|
|
75
|
+
`node -e "console.log(JSON.stringify(require('./release.config.mjs').default.plugins[0][1]))" > release.config.mjs.json`.
|
|
76
|
+
|
|
77
|
+
`--target` is the git branch name, resolved through the same `branchTargets` map as a normal
|
|
78
|
+
publish.
|
|
79
|
+
|
|
80
|
+
## Running these by hand from GitHub Actions
|
|
81
|
+
|
|
82
|
+
Add `workflow_dispatch` to the workflow that runs `semantic-release`, alongside its existing
|
|
83
|
+
trigger:
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
on:
|
|
87
|
+
push:
|
|
88
|
+
branches: [main]
|
|
89
|
+
workflow_dispatch: {}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`semantic-release` still decides whether a release is needed. A manual run with no new
|
|
93
|
+
commits logs "no release published" and exits clean.
|
|
94
|
+
|
|
95
|
+
Tag recovery needs its own workflow, since it takes a branch name as input instead of reading
|
|
96
|
+
the one that triggered the run:
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
name: apply-tags
|
|
100
|
+
|
|
101
|
+
on:
|
|
102
|
+
workflow_dispatch:
|
|
103
|
+
inputs:
|
|
104
|
+
target:
|
|
105
|
+
description: Branch name, resolved through branchTargets like a normal publish
|
|
106
|
+
required: true
|
|
107
|
+
|
|
108
|
+
jobs:
|
|
109
|
+
apply-tags:
|
|
110
|
+
runs-on: ubuntu-latest
|
|
111
|
+
steps:
|
|
112
|
+
- uses: actions/checkout@v7
|
|
113
|
+
- uses: actions/setup-node@v7
|
|
114
|
+
with:
|
|
115
|
+
node-version: '22'
|
|
116
|
+
- run: npm ci
|
|
117
|
+
- run: npx semantic-release-steam-apply-tags --config release.config.mjs.json --target "${{ github.event.inputs.target }}"
|
|
118
|
+
env:
|
|
119
|
+
STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }}
|
|
120
|
+
STEAM_CONFIG_VDF_B64: ${{ secrets.STEAM_CONFIG_VDF_B64 }}
|
|
121
|
+
STEAM_PASSWORD: ${{ secrets.STEAM_PASSWORD }}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Run it from the repo's Actions tab and pick the branch. It retags the item without touching
|
|
125
|
+
SteamCMD or cutting a release.
|
|
126
|
+
|
|
127
|
+
## Forcing a release
|
|
128
|
+
|
|
129
|
+
`semantic-release` reads commit messages to decide the version bump. It has no flag for
|
|
130
|
+
forcing one. To force a release on demand, push an empty commit with a conventional-commit
|
|
131
|
+
message, then run `semantic-release` in the same job:
|
|
132
|
+
|
|
133
|
+
```yaml
|
|
134
|
+
name: force-release
|
|
135
|
+
|
|
136
|
+
on:
|
|
137
|
+
workflow_dispatch:
|
|
138
|
+
inputs:
|
|
139
|
+
bump:
|
|
140
|
+
description: Release type to force
|
|
141
|
+
required: true
|
|
142
|
+
type: choice
|
|
143
|
+
options: [patch, minor, major]
|
|
144
|
+
message:
|
|
145
|
+
description: Commit message, written into the changelog
|
|
146
|
+
required: true
|
|
147
|
+
|
|
148
|
+
jobs:
|
|
149
|
+
force-release:
|
|
150
|
+
runs-on: ubuntu-latest
|
|
151
|
+
steps:
|
|
152
|
+
- uses: actions/checkout@v7
|
|
153
|
+
- uses: actions/setup-node@v7
|
|
154
|
+
with:
|
|
155
|
+
node-version: '22'
|
|
156
|
+
- run: npm ci
|
|
157
|
+
- run: |
|
|
158
|
+
git config user.name "github-actions[bot]"
|
|
159
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
160
|
+
case "${{ inputs.bump }}" in
|
|
161
|
+
patch) type=fix ;;
|
|
162
|
+
minor) type=feat ;;
|
|
163
|
+
major) type='feat!' ;;
|
|
164
|
+
esac
|
|
165
|
+
git commit --allow-empty -m "$type: ${{ inputs.message }}"
|
|
166
|
+
git push
|
|
167
|
+
- run: npx semantic-release
|
|
168
|
+
env:
|
|
169
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
170
|
+
STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }}
|
|
171
|
+
STEAM_CONFIG_VDF_B64: ${{ secrets.STEAM_CONFIG_VDF_B64 }}
|
|
172
|
+
STEAM_PASSWORD: ${{ secrets.STEAM_PASSWORD }}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Run `semantic-release` in this same job instead of relying on the push above to trigger your
|
|
176
|
+
normal release workflow. A push made with the default `GITHUB_TOKEN` doesn't trigger other
|
|
177
|
+
workflows, so the push-based workflow would never run.
|
|
178
|
+
|
|
60
179
|
## License
|
|
61
180
|
|
|
62
181
|
[MIT](./LICENSE)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { verifySteamPublishConfig } from '../lib/config.mjs';
|
|
6
|
+
import { applyWorkshopTags } from '../lib/workshop-tags.mjs';
|
|
7
|
+
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const args = { config: null, target: null };
|
|
10
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
11
|
+
if (argv[i] === '--config') args.config = argv[i + 1];
|
|
12
|
+
if (argv[i] === '--target') args.target = argv[i + 1];
|
|
13
|
+
}
|
|
14
|
+
return args;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveTags(mod, target) {
|
|
18
|
+
return mod.metadata?.[target]?.tags ?? mod.tags;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function run({
|
|
22
|
+
argv = process.argv.slice(2),
|
|
23
|
+
env = process.env,
|
|
24
|
+
logger = console,
|
|
25
|
+
readConfig = path => readFile(path, 'utf8'),
|
|
26
|
+
verifyConfig = verifySteamPublishConfig,
|
|
27
|
+
applyTags = applyWorkshopTags,
|
|
28
|
+
} = {}) {
|
|
29
|
+
const { config: configPath, target: branchName } = parseArgs(argv);
|
|
30
|
+
if (!configPath || !branchName) {
|
|
31
|
+
throw new Error('usage: semantic-release-steam-apply-tags --config <file> --target <branch>');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pluginConfig = JSON.parse(await readConfig(configPath));
|
|
35
|
+
|
|
36
|
+
const hasBranchTargets = pluginConfig.branchTargets && typeof pluginConfig.branchTargets === 'object'
|
|
37
|
+
&& !Array.isArray(pluginConfig.branchTargets);
|
|
38
|
+
const hasMods = Array.isArray(pluginConfig.mods);
|
|
39
|
+
if (!hasBranchTargets || !hasMods) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Config file "${configPath}" must contain "branchTargets" (an object) and "mods" (an array) - ` +
|
|
42
|
+
'the same options you pass to the semantic-release-steam plugin, not the full release.config.mjs. ' +
|
|
43
|
+
`Found top-level keys: [${Object.keys(pluginConfig).join(', ')}].`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const state = await verifyConfig({
|
|
48
|
+
env,
|
|
49
|
+
branchName,
|
|
50
|
+
branchTargets: pluginConfig.branchTargets,
|
|
51
|
+
mods: pluginConfig.mods,
|
|
52
|
+
appId: pluginConfig.appId,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!state.shouldPublish) {
|
|
56
|
+
throw new Error(`Branch "${branchName}" has no configured target in branchTargets.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const mods = state.mods.map(mod => ({
|
|
60
|
+
name: mod.name,
|
|
61
|
+
publishedFileId: mod.workshopIds[state.target],
|
|
62
|
+
tags: resolveTags(mod, state.target),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
await applyTags({
|
|
66
|
+
steamUsername: env.STEAM_USERNAME,
|
|
67
|
+
steamPassword: env.STEAM_PASSWORD,
|
|
68
|
+
steamRefreshToken: env.STEAM_REFRESH_TOKEN,
|
|
69
|
+
appId: pluginConfig.appId,
|
|
70
|
+
mods,
|
|
71
|
+
logger,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isMainModule() {
|
|
76
|
+
if (!process.argv[1]) return false;
|
|
77
|
+
try {
|
|
78
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isMainModule()) {
|
|
85
|
+
run().catch(error => {
|
|
86
|
+
console.error(error.message);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
});
|
|
89
|
+
}
|
package/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { buildSteamDescription } from './lib/description.mjs';
|
|
|
4
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
|
+
import { applyWorkshopTags } from './lib/workshop-tags.mjs';
|
|
7
8
|
|
|
8
9
|
function isDryRun(context) {
|
|
9
10
|
return Boolean(context?.options?.dryRun);
|
|
@@ -27,6 +28,7 @@ function resolveOverrides(context) {
|
|
|
27
28
|
uploadItem: context.uploadWorkshopItem ?? uploadWorkshopItem,
|
|
28
29
|
compile: context.compileReadme ?? compileReadme,
|
|
29
30
|
writeCompiled: context.writeCompiledReadme ?? writeCompiledReadme,
|
|
31
|
+
applyTags: context.applyWorkshopTags ?? applyWorkshopTags,
|
|
30
32
|
};
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -83,6 +85,8 @@ export async function publish(pluginConfig, context) {
|
|
|
83
85
|
? pluginConfig.assetBaseUrlTemplate.replace('{branch}', context.branch.name)
|
|
84
86
|
: '';
|
|
85
87
|
|
|
88
|
+
const taggableMods = [];
|
|
89
|
+
|
|
86
90
|
for (const mod of state.mods) {
|
|
87
91
|
const modPath = resolve(cwd, mod.path);
|
|
88
92
|
const markdown = await compileModReadme(pluginConfig, modPath, overrides);
|
|
@@ -117,6 +121,7 @@ export async function publish(pluginConfig, context) {
|
|
|
117
121
|
steamCmdPath: context.env.STEAMCMD_PATH ?? '~/steamcmd/steamcmd.sh',
|
|
118
122
|
steamUsername: context.env.STEAM_USERNAME,
|
|
119
123
|
steamConfigPath: state.steamConfigPath,
|
|
124
|
+
steamHome: state.steamHome,
|
|
120
125
|
appId: pluginConfig.appId,
|
|
121
126
|
stagePath,
|
|
122
127
|
publishedFileId,
|
|
@@ -132,6 +137,18 @@ export async function publish(pluginConfig, context) {
|
|
|
132
137
|
});
|
|
133
138
|
|
|
134
139
|
context.logger.log(`Published ${mod.name} to ${state.target} workshop item ${publishedFileId}`);
|
|
140
|
+
taggableMods.push({ name: mod.name, publishedFileId, tags: metadata.tags });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!dryRun) {
|
|
144
|
+
await overrides.applyTags({
|
|
145
|
+
steamUsername: context.env.STEAM_USERNAME,
|
|
146
|
+
steamPassword: context.env.STEAM_PASSWORD,
|
|
147
|
+
steamRefreshToken: context.env.STEAM_REFRESH_TOKEN,
|
|
148
|
+
appId: pluginConfig.appId,
|
|
149
|
+
mods: taggableMods,
|
|
150
|
+
logger: context.logger,
|
|
151
|
+
});
|
|
135
152
|
}
|
|
136
153
|
|
|
137
154
|
return undefined;
|
package/lib/config.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, writeFile, symlink } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
@@ -49,11 +49,15 @@ export async function resolveSteamConfigPath(env) {
|
|
|
49
49
|
|
|
50
50
|
if (env.STEAM_CONFIG_VDF_B64 && env.STEAM_CONFIG_VDF_B64.trim().length > 0) {
|
|
51
51
|
const buffer = Buffer.from(env.STEAM_CONFIG_VDF_B64, 'base64');
|
|
52
|
-
const
|
|
52
|
+
const home = join(tmpdir(), `semantic-release-steam-${randomBytes(6).toString('hex')}`);
|
|
53
|
+
const steamDir = join(home, '.steam');
|
|
54
|
+
const dir = join(steamDir, 'config');
|
|
53
55
|
await mkdir(dir, { recursive: true });
|
|
56
|
+
await symlink(steamDir, join(steamDir, 'root'));
|
|
57
|
+
await symlink(steamDir, join(steamDir, 'steam'));
|
|
54
58
|
const path = join(dir, 'config.vdf');
|
|
55
59
|
await writeFile(path, buffer);
|
|
56
|
-
return path;
|
|
60
|
+
return { path, home };
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
return null;
|
|
@@ -81,13 +85,16 @@ export async function verifySteamPublishConfig({
|
|
|
81
85
|
);
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
const
|
|
85
|
-
if (!
|
|
88
|
+
const resolvedConfig = await resolveSteamConfigPath(env);
|
|
89
|
+
if (!resolvedConfig) {
|
|
86
90
|
throw new Error(
|
|
87
91
|
'Steam config.vdf path is required for Steam publishing. ' +
|
|
88
92
|
'Set STEAM_CONFIG_VDF=/path/to/config.vdf, or STEAM_CONFIG_VDF_B64=<base64 of config.vdf contents>.',
|
|
89
93
|
);
|
|
90
94
|
}
|
|
95
|
+
const steamConfig = typeof resolvedConfig === 'string'
|
|
96
|
+
? { path: resolvedConfig, home: null }
|
|
97
|
+
: resolvedConfig;
|
|
91
98
|
|
|
92
99
|
if (!Array.isArray(mods) || mods.length === 0) {
|
|
93
100
|
throw new Error('mods is required and must contain at least one entry');
|
|
@@ -132,5 +139,21 @@ export async function verifySteamPublishConfig({
|
|
|
132
139
|
);
|
|
133
140
|
}
|
|
134
141
|
|
|
135
|
-
|
|
142
|
+
const needsTagCredentials = publishable.some(
|
|
143
|
+
mod => (mod.metadata?.[target]?.tags ?? mod.tags)?.length > 0,
|
|
144
|
+
);
|
|
145
|
+
if (needsTagCredentials && !env.STEAM_PASSWORD && !env.STEAM_REFRESH_TOKEN) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
'STEAM_PASSWORD or STEAM_REFRESH_TOKEN is required when a mod configures tags. ' +
|
|
148
|
+
'Set one of them, or remove tags to skip the Workshop tags step.',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
shouldPublish: true,
|
|
154
|
+
target,
|
|
155
|
+
mods: publishable,
|
|
156
|
+
steamConfigPath: steamConfig.path,
|
|
157
|
+
steamHome: steamConfig.home,
|
|
158
|
+
};
|
|
136
159
|
}
|
package/lib/steamcmd.mjs
CHANGED
|
@@ -39,6 +39,7 @@ export async function uploadWorkshopItem({
|
|
|
39
39
|
steamCmdPath,
|
|
40
40
|
steamUsername,
|
|
41
41
|
steamConfigPath,
|
|
42
|
+
steamHome,
|
|
42
43
|
stagePath,
|
|
43
44
|
publishedFileId,
|
|
44
45
|
changenote,
|
|
@@ -82,6 +83,7 @@ export async function uploadWorkshopItem({
|
|
|
82
83
|
env: {
|
|
83
84
|
...process.env,
|
|
84
85
|
STEAM_CONFIG_VDF: steamConfigPath,
|
|
86
|
+
...(steamHome ? { HOME: steamHome } : {}),
|
|
85
87
|
},
|
|
86
88
|
});
|
|
87
89
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const LOGIN_TIMEOUT_MS = 120_000;
|
|
2
|
+
const CONNECT_TIMEOUT_MS = 30_000;
|
|
3
|
+
const UPDATE_TIMEOUT_MS = 30_000;
|
|
4
|
+
const LOGOFF_TIMEOUT_MS = 5_000;
|
|
5
|
+
const VERIFY_ATTEMPTS = 3;
|
|
6
|
+
const VERIFY_RETRY_DELAY_MS = 2_000;
|
|
7
|
+
|
|
8
|
+
const GUARD_TYPE_DEVICE_CONFIRMATION = 4;
|
|
9
|
+
const GUARD_TYPE_EMAIL_CONFIRMATION = 5;
|
|
10
|
+
|
|
11
|
+
export function canPollForGuardApproval(validActions) {
|
|
12
|
+
return (validActions ?? []).some(
|
|
13
|
+
action => action.type === GUARD_TYPE_DEVICE_CONFIRMATION || action.type === GUARD_TYPE_EMAIL_CONFIRMATION,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function loginWithPassword({ steamUsername, steamPassword, logger }) {
|
|
18
|
+
const { LoginSession, EAuthTokenPlatformType } = await import('steam-session');
|
|
19
|
+
const session = new LoginSession(EAuthTokenPlatformType.SteamClient);
|
|
20
|
+
session.loginTimeout = LOGIN_TIMEOUT_MS;
|
|
21
|
+
|
|
22
|
+
const startResult = await session.startWithCredentials({
|
|
23
|
+
accountName: steamUsername,
|
|
24
|
+
password: steamPassword,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (startResult.actionRequired) {
|
|
28
|
+
if (!canPollForGuardApproval(startResult.validActions)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'Steam Guard on this account needs an email or mobile authenticator code, which STEAM_PASSWORD ' +
|
|
31
|
+
'cannot approve automatically. Use STEAM_REFRESH_TOKEN instead, or enable app-based confirmation.',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
logger.log('Approve the Steam Guard prompt on your phone to publish Workshop tags.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
|
|
38
|
+
session.once('authenticated', () => resolve());
|
|
39
|
+
session.once('timeout', () => reject(new Error('Steam Guard approval timed out')));
|
|
40
|
+
session.once('error', reject);
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
return session.refreshToken;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function connectWithTimeout(user, timeoutMs, trigger) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const settle = (fn, value) => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
user.removeAllListeners('loggedOn');
|
|
51
|
+
user.removeAllListeners('error');
|
|
52
|
+
fn(value);
|
|
53
|
+
};
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
user.logOff();
|
|
56
|
+
settle(reject, new Error('Steam CM connection timed out'));
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
user.once('loggedOn', () => settle(resolve));
|
|
59
|
+
user.once('error', err => settle(reject, err));
|
|
60
|
+
trigger();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function connectToCM(refreshToken) {
|
|
65
|
+
const SteamUser = (await import('steam-user')).default;
|
|
66
|
+
const user = new SteamUser({ autoRelogin: false });
|
|
67
|
+
|
|
68
|
+
await connectWithTimeout(user, CONNECT_TIMEOUT_MS, () => user.logOn({ refreshToken }));
|
|
69
|
+
|
|
70
|
+
const died = new Promise((_resolve, reject) => {
|
|
71
|
+
user.on('error', reject);
|
|
72
|
+
user.on('disconnected', eresult => reject(new Error(`Steam CM session disconnected (eresult ${eresult})`)));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { user, died };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function sendTagUpdate(user, { appid, publishedfileid, tags }) {
|
|
79
|
+
const SteamUser = (await import('steam-user')).default;
|
|
80
|
+
const Schema = (await import('steam-user/protobufs/generated/_load.js')).default;
|
|
81
|
+
|
|
82
|
+
return /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
|
|
83
|
+
const body = Schema.CPublishedFile_Update_Request.encode({ appid, publishedfileid, tags }).finish();
|
|
84
|
+
const timer = setTimeout(() => reject(new Error('PublishedFile.Update#1 timed out')), UPDATE_TIMEOUT_MS);
|
|
85
|
+
|
|
86
|
+
user._send(
|
|
87
|
+
{ msg: SteamUser.EMsg.ServiceMethodCallFromClient, proto: { target_job_name: 'PublishedFile.Update#1' } },
|
|
88
|
+
Buffer.from(body),
|
|
89
|
+
(_body, header) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
const eresult = header?.proto?.eresult;
|
|
92
|
+
if (eresult !== SteamUser.EResult.OK) {
|
|
93
|
+
reject(new Error(`PublishedFile.Update#1 for ${publishedfileid} returned eresult ${eresult}`));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
resolve();
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fetchPublishedTags(user, publishedfileid, expectedTags) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
user.getPublishedFileDetails([publishedfileid], (err, results) => {
|
|
105
|
+
if (err) {
|
|
106
|
+
reject(err);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const actual = (results[publishedfileid]?.tags ?? []).map(t => (t.tag ?? t).toLowerCase()).sort();
|
|
110
|
+
const expected = expectedTags.map(t => t.toLowerCase()).sort();
|
|
111
|
+
resolve({ matches: JSON.stringify(actual) === JSON.stringify(expected), actual });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function verifyPublishedTags(
|
|
117
|
+
user,
|
|
118
|
+
publishedfileid,
|
|
119
|
+
expectedTags,
|
|
120
|
+
attempts = VERIFY_ATTEMPTS,
|
|
121
|
+
delayMs = VERIFY_RETRY_DELAY_MS,
|
|
122
|
+
) {
|
|
123
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
124
|
+
const result = await fetchPublishedTags(user, publishedfileid, expectedTags);
|
|
125
|
+
if (result.matches || attempt === attempts) {
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function logOffAndWait(user, timeoutMs = LOGOFF_TIMEOUT_MS) {
|
|
134
|
+
return /** @type {Promise<void>} */ (new Promise(resolve => {
|
|
135
|
+
if (!user.steamID) {
|
|
136
|
+
resolve();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
140
|
+
user.once('disconnected', () => { clearTimeout(timer); resolve(); });
|
|
141
|
+
user.logOff();
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function applyWorkshopTags({
|
|
146
|
+
steamUsername,
|
|
147
|
+
steamPassword,
|
|
148
|
+
steamRefreshToken,
|
|
149
|
+
appId,
|
|
150
|
+
mods,
|
|
151
|
+
logger,
|
|
152
|
+
login = loginWithPassword,
|
|
153
|
+
connect = connectToCM,
|
|
154
|
+
disconnect = logOffAndWait,
|
|
155
|
+
sendUpdate = sendTagUpdate,
|
|
156
|
+
verify = verifyPublishedTags,
|
|
157
|
+
}) {
|
|
158
|
+
const taggedMods = mods.filter(mod => Array.isArray(mod.tags) && mod.tags.length > 0);
|
|
159
|
+
if (taggedMods.length === 0) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const refreshToken = steamRefreshToken || await login({ steamUsername, steamPassword, logger });
|
|
164
|
+
const { user, died } = await connect(refreshToken);
|
|
165
|
+
|
|
166
|
+
const tagged = [];
|
|
167
|
+
try {
|
|
168
|
+
for (const mod of taggedMods) {
|
|
169
|
+
await Promise.race([
|
|
170
|
+
sendUpdate(user, { appid: appId, publishedfileid: mod.publishedFileId, tags: mod.tags }),
|
|
171
|
+
died,
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
const result = await Promise.race([verify(user, mod.publishedFileId, mod.tags), died]);
|
|
175
|
+
if (!result.matches) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Workshop tags for "${mod.name}" did not verify after the update. ` +
|
|
178
|
+
`Sent [${mod.tags.join(', ')}], Steam now reports [${result.actual.join(', ')}].`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
tagged.push(mod.name);
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const untagged = taggedMods.slice(tagged.length).map(mod => mod.name);
|
|
186
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Workshop tag update failed: ${message} Tagged: [${tagged.join(', ')}]. ` +
|
|
189
|
+
`Still untagged on Workshop: [${untagged.join(', ')}].`,
|
|
190
|
+
{ cause: error },
|
|
191
|
+
);
|
|
192
|
+
} finally {
|
|
193
|
+
await disconnect(user);
|
|
194
|
+
}
|
|
195
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "semantic-release-steam",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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
7
|
"types": "./index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"semantic-release-steam-apply-tags": "./bin/apply-tags.mjs"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": {
|
|
10
13
|
"types": "./index.d.ts",
|
|
@@ -16,6 +19,7 @@
|
|
|
16
19
|
"files": [
|
|
17
20
|
"index.mjs",
|
|
18
21
|
"index.d.ts",
|
|
22
|
+
"bin/",
|
|
19
23
|
"lib/",
|
|
20
24
|
"schema/",
|
|
21
25
|
"LICENSE",
|
|
@@ -62,7 +66,9 @@
|
|
|
62
66
|
}
|
|
63
67
|
},
|
|
64
68
|
"dependencies": {
|
|
65
|
-
"@steamdown/core": "^1.0.0-beta.2"
|
|
69
|
+
"@steamdown/core": "^1.0.0-beta.2",
|
|
70
|
+
"steam-session": "^1.9.4",
|
|
71
|
+
"steam-user": "^5.3.0"
|
|
66
72
|
},
|
|
67
73
|
"devDependencies": {
|
|
68
74
|
"@eslint/js": "^10.0.1",
|