dsh-plugin-toolkit 0.3.5 → 0.4.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/CHANGELOG.md +13 -0
- package/README.md +15 -3
- package/lib/cli.mjs +30 -2
- package/lib/index-file.mjs +9 -2
- package/lib/pr.mjs +144 -0
- package/lib/registry.mjs +5 -0
- package/lib/release.mjs +89 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
`release` can now finish the job instead of printing the next command.
|
|
6
|
+
|
|
7
|
+
- `--pr` commits the index on `market/<version>`, pushes it, and opens the pull request with `gh`.
|
|
8
|
+
- `--merge` (with `--pr`) waits for `gh pr checks` to settle and squash-merges.
|
|
9
|
+
- A repository with no CI is treated as settled rather than waited on; `gh` prints "no checks reported" on **stderr** and exits 1, which is why stderr is now piped instead of dropped.
|
|
10
|
+
- Re-running a release after a failure no longer throws: the same version with the same publish time is a no-op, and a different timestamp stops with both values in the message.
|
|
11
|
+
- A package the registry has never heard of fails immediately (`the registry has no package named …`) instead of polling for eight minutes.
|
|
12
|
+
- `release` forwards `fetch`, so a caller can answer instead of npm.
|
|
13
|
+
|
|
14
|
+
Verified end to end against a throwaway package: tag → publish → registry wait → index write → PR #3 → checks → merged.
|
|
15
|
+
|
|
3
16
|
## 0.3.5
|
|
4
17
|
|
|
5
18
|
Four bugs found by running `release` for real against a throwaway package, instead of only `--dry-run`:
|
package/README.md
CHANGED
|
@@ -166,10 +166,22 @@ Exit code is `0` when the plugin can be installed and `1` when it cannot, so it
|
|
|
166
166
|
2. Refuse to run on a dirty tree, or if the tag already exists.
|
|
167
167
|
3. Create and push the tag, which starts the plugin's release workflow.
|
|
168
168
|
4. Poll the registry until it actually serves that version, reporting each wait.
|
|
169
|
-
5. Write the index entry with the publish time **the registry reports**, not the local clock.
|
|
170
|
-
6.
|
|
169
|
+
5. Write the index entry with the publish time **the registry reports**, not the local clock. It waits for `time[version]` specifically: the abbreviated registry document has no `time` field, and an entry with a null timestamp is rejected by the market checker.
|
|
170
|
+
6. With `--pr`: commit that entry on `market/<version>`, push it, and open the pull request. With `--merge` as well: wait for `gh pr checks` to settle, then squash-merge.
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
```sh
|
|
173
|
+
npx dsh-plugin release . --pr --merge
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
tagged plugin-v0.4.0, npm served my-plugin@0.4.0 after 82s
|
|
178
|
+
updated market/index.json on market/0.4.0
|
|
179
|
+
opened https://github.com/me/my-plugin/pull/3
|
|
180
|
+
checks settled after 1s (1 look(s))
|
|
181
|
+
merged
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Without `--pr` it prints the `git add` / `gh pr create` commands instead of running them; `--dry-run` prints the whole sequence before anything happens. A re-run after a failure is safe: the same version with the same publish time is a no-op.
|
|
173
185
|
|
|
174
186
|
## Exit codes
|
|
175
187
|
|
package/lib/cli.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { auditVersions, readIndex } from './index-file.mjs';
|
|
|
12
12
|
export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugins
|
|
13
13
|
|
|
14
14
|
dsh-plugin doctor [dir] check the manifest, README and bundle
|
|
15
|
-
dsh-plugin release [dir] [flags] tag, wait for npm, update the market index
|
|
15
|
+
dsh-plugin release [dir] [flags] tag, wait for npm, update the market index, open the PR
|
|
16
16
|
dsh-plugin index [dir] audit every version in market/index.json
|
|
17
17
|
dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
|
|
18
18
|
dsh-plugin dev [dir] [flags] copy a rebuilt bundle into the installed plugin and confirm the reload
|
|
@@ -22,6 +22,9 @@ Flags
|
|
|
22
22
|
--index <path> index file, default market/index.json
|
|
23
23
|
--tag-prefix <prefix> default plugin-v
|
|
24
24
|
--budget <seconds> how long to wait for npm, default 480
|
|
25
|
+
--pr commit the index on a branch, push it, open the pull request
|
|
26
|
+
--merge with --pr: wait for checks, then squash-merge
|
|
27
|
+
--base <branch> base branch for the pull request, default main
|
|
25
28
|
`;
|
|
26
29
|
|
|
27
30
|
function flags(argv) {
|
|
@@ -42,6 +45,9 @@ function flags(argv) {
|
|
|
42
45
|
else if (token === '--once') parsed.once = true;
|
|
43
46
|
else if (token === '--debounce') parsed.debounce = Number(argv[++index]);
|
|
44
47
|
else if (token === '--timeout') parsed.timeout = Number(argv[++index]);
|
|
48
|
+
else if (token === '--pr') parsed.pr = true;
|
|
49
|
+
else if (token === '--merge') parsed.merge = true;
|
|
50
|
+
else if (token === '--base') parsed.base = argv[++index];
|
|
45
51
|
else if (token.startsWith('--') === true) throw new Error(`unknown flag ${token}`);
|
|
46
52
|
else parsed.positional.push(token);
|
|
47
53
|
}
|
|
@@ -152,6 +158,10 @@ export async function run(argv, io = console) {
|
|
|
152
158
|
indexFile: options.index,
|
|
153
159
|
tagPrefix: options.tagPrefix,
|
|
154
160
|
budgetMs: options.budget === undefined ? undefined : options.budget * 1000,
|
|
161
|
+
pr: options.pr === true,
|
|
162
|
+
merge: options.merge === true,
|
|
163
|
+
base: options.base,
|
|
164
|
+
onStep: (name) => io.log(` → ${name}`),
|
|
155
165
|
onWait: ({ attempts, waitedMs }) => {
|
|
156
166
|
io.log(` … ${name}@${version} not on the registry yet (${attempts} checks, ${Math.round(waitedMs / 1000)}s)`);
|
|
157
167
|
},
|
|
@@ -164,12 +174,30 @@ export async function run(argv, io = console) {
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
io.log(`tagged ${result.tag}, npm served ${name}@${version} after ${Math.round(result.waitedMs / 1000)}s`);
|
|
167
|
-
if (result.indexUpdate !== null) {
|
|
177
|
+
if (result.indexUpdate !== null && result.indexUpdate.unchanged === true) {
|
|
178
|
+
io.log(`${result.indexUpdate.file} already carried ${version}; nothing to commit`);
|
|
179
|
+
} else if (result.indexUpdate !== null && result.pullRequest === undefined) {
|
|
168
180
|
io.log(`updated ${result.indexUpdate.file} — commit it and open the PR:`);
|
|
169
181
|
io.log(` git add ${result.indexUpdate.file} && git commit -m "market: ${version}" && gh pr create --fill`);
|
|
182
|
+
} else if (result.indexUpdate !== null) {
|
|
183
|
+
io.log(`updated ${result.indexUpdate.file} on ${result.pullRequest.branch}`);
|
|
170
184
|
} else {
|
|
171
185
|
io.log('no market/index.json here, so only the tag and the publish happened');
|
|
172
186
|
}
|
|
187
|
+
|
|
188
|
+
if (result.pullRequest !== undefined) {
|
|
189
|
+
if (result.pullRequest.ok !== true) {
|
|
190
|
+
io.error(`the pull request was not opened (${result.pullRequest.stage})`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
io.log(`opened ${result.pullRequest.url}`);
|
|
194
|
+
}
|
|
195
|
+
if (result.checks !== undefined) {
|
|
196
|
+
io.log(`checks settled after ${Math.round(result.checks.waitedMs / 1000)}s (${result.checks.attempts} look(s))`);
|
|
197
|
+
}
|
|
198
|
+
if (result.merged !== undefined && result.merged.ok === true) {
|
|
199
|
+
io.log('merged');
|
|
200
|
+
}
|
|
173
201
|
return 0;
|
|
174
202
|
}
|
|
175
203
|
|
package/lib/index-file.mjs
CHANGED
|
@@ -34,8 +34,15 @@ export function addVersion(index, name, entry) {
|
|
|
34
34
|
throw new Error(`the index has no plugin called "${name}"`);
|
|
35
35
|
}
|
|
36
36
|
const versions = Array.isArray(plugin.versions) ? plugin.versions : [];
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const existing = versions.find((candidate) => candidate.version === entry.version);
|
|
38
|
+
if (existing !== undefined) {
|
|
39
|
+
// Re-running a release after a failure is normal, so the same version with
|
|
40
|
+
// the same publish time is a no-op; a different timestamp means the index
|
|
41
|
+
// and the registry disagree, and that is worth stopping for.
|
|
42
|
+
if (existing.publishTimestamp === entry.publishTimestamp) return index;
|
|
43
|
+
throw new Error(
|
|
44
|
+
`${name}@${entry.version} is already in the index with publishTimestamp ${existing.publishTimestamp}, not ${entry.publishTimestamp}`,
|
|
45
|
+
);
|
|
39
46
|
}
|
|
40
47
|
return {
|
|
41
48
|
...index,
|
package/lib/pr.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last mile of a release: the index change is one line, but it still needs a
|
|
3
|
+
* branch, a pull request, a green CI, and a merge.
|
|
4
|
+
*
|
|
5
|
+
* Every side effect goes through an injected runner, so the sequence can be
|
|
6
|
+
* asserted without a network, a repository, or a `gh` login.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
// stderr is piped, not inherited: git's "fatal: ambiguous argument" for a tag
|
|
11
|
+
// that does not exist yet and gh's "no checks reported" both arrive there, and
|
|
12
|
+
// both are expected misses that must not read as failures — but they still have
|
|
13
|
+
// to be readable by the caller.
|
|
14
|
+
export function defaultGit(dir) {
|
|
15
|
+
return (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function defaultGh(dir) {
|
|
19
|
+
return (args) => execFileSync('gh', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function branchName(version, prefix = 'market/') {
|
|
23
|
+
return `${prefix}${version}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Commit the index on its own branch, push it, and open the pull request.
|
|
28
|
+
* Returns `{ok, branch, url}` or `{ok:false, stage, reason}`.
|
|
29
|
+
*/
|
|
30
|
+
export async function openIndexPr(options) {
|
|
31
|
+
const {
|
|
32
|
+
dir,
|
|
33
|
+
indexFile,
|
|
34
|
+
version,
|
|
35
|
+
base = 'main',
|
|
36
|
+
branch = branchName(version),
|
|
37
|
+
git = defaultGit(dir),
|
|
38
|
+
gh = defaultGh(dir),
|
|
39
|
+
title,
|
|
40
|
+
body,
|
|
41
|
+
onStep,
|
|
42
|
+
} = options;
|
|
43
|
+
const step = (name) => {
|
|
44
|
+
if (typeof onStep === 'function') onStep(name);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
step('branch');
|
|
49
|
+
git(['checkout', '-b', branch]);
|
|
50
|
+
} catch {
|
|
51
|
+
// An existing branch means a previous run got this far; reuse it rather than
|
|
52
|
+
// failing on the one step that is safe to repeat.
|
|
53
|
+
try {
|
|
54
|
+
git(['checkout', branch]);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return { ok: false, stage: 'branch', reason: `could not create or check out ${branch}: ${error.message}` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
step('commit');
|
|
62
|
+
git(['add', indexFile]);
|
|
63
|
+
git(['commit', '-m', `market: ${version}`]);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { ok: false, stage: 'commit', reason: error.message };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
step('push');
|
|
70
|
+
git(['push', '-u', 'origin', branch]);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return { ok: false, stage: 'push', reason: error.message };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
step('pr');
|
|
77
|
+
const url = gh([
|
|
78
|
+
'pr',
|
|
79
|
+
'create',
|
|
80
|
+
'--base',
|
|
81
|
+
base,
|
|
82
|
+
'--head',
|
|
83
|
+
branch,
|
|
84
|
+
'--title',
|
|
85
|
+
title ?? `market: ${version}`,
|
|
86
|
+
'--body',
|
|
87
|
+
body ?? `Adds ${version} to the market index, with the publish time the registry reported.`,
|
|
88
|
+
]);
|
|
89
|
+
return { ok: true, branch, url: url.split('\n').pop() ?? url };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { ok: false, stage: 'pr', reason: error.message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Poll `gh pr checks` until every check has settled. */
|
|
96
|
+
export async function waitForChecks(options) {
|
|
97
|
+
const {
|
|
98
|
+
url,
|
|
99
|
+
gh,
|
|
100
|
+
budgetMs = 30 * 60 * 1000,
|
|
101
|
+
intervalMs = 20 * 1000,
|
|
102
|
+
now = Date.now,
|
|
103
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
104
|
+
onWait,
|
|
105
|
+
} = options;
|
|
106
|
+
const started = now();
|
|
107
|
+
let attempts = 0;
|
|
108
|
+
for (;;) {
|
|
109
|
+
attempts += 1;
|
|
110
|
+
let output = '';
|
|
111
|
+
try {
|
|
112
|
+
output = gh(['pr', 'checks', url, '--json', 'state', '--jq', '.[].state']);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// `gh pr checks` exits non-zero while checks are still pending, and also
|
|
115
|
+
// when the repository has no CI at all — the second case is not a wait.
|
|
116
|
+
output = String(error.stdout ?? '');
|
|
117
|
+
// `gh` writes "no checks reported on the 'x' branch" to stdout and exits 1.
|
|
118
|
+
const message = `${error.message ?? ''}${error.stdout ?? ''}${error.stderr ?? ''}`;
|
|
119
|
+
if (/no checks reported/i.test(message) === true) {
|
|
120
|
+
return { ok: true, attempts, waitedMs: now() - started, states: [], noChecks: true };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const states = output.split('\n').map((line) => line.trim()).filter((line) => line !== '');
|
|
124
|
+
const pending = states.filter((state) => ['PENDING', 'QUEUED', 'IN_PROGRESS', ''].includes(state));
|
|
125
|
+
const failed = states.filter((state) => ['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED'].includes(state));
|
|
126
|
+
if (failed.length > 0) return { ok: false, attempts, waitedMs: now() - started, states, reason: `${failed.length} check(s) failed` };
|
|
127
|
+
if (states.length > 0 && pending.length === 0) return { ok: true, attempts, waitedMs: now() - started, states };
|
|
128
|
+
if (now() - started >= budgetMs) {
|
|
129
|
+
return { ok: false, attempts, waitedMs: now() - started, states, timedOut: true, reason: 'checks did not settle in time' };
|
|
130
|
+
}
|
|
131
|
+
if (typeof onWait === 'function') onWait({ attempts, waitedMs: now() - started, states });
|
|
132
|
+
await sleep(intervalMs);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function mergePr(options) {
|
|
137
|
+
const { url, gh, method = 'squash' } = options;
|
|
138
|
+
try {
|
|
139
|
+
gh(['pr', 'merge', url, `--${method}`]);
|
|
140
|
+
return { ok: true };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return { ok: false, reason: error.message };
|
|
143
|
+
}
|
|
144
|
+
}
|
package/lib/registry.mjs
CHANGED
|
@@ -60,6 +60,11 @@ export async function waitForVersion(name, version, options = {}) {
|
|
|
60
60
|
for (;;) {
|
|
61
61
|
attempts += 1;
|
|
62
62
|
const document = await packument(name, options);
|
|
63
|
+
if (document === null && options.requirePackage !== false) {
|
|
64
|
+
// A package that does not exist will not appear by waiting: say so now
|
|
65
|
+
// instead of polling for eight minutes.
|
|
66
|
+
return { document, attempts, waitedMs: now() - started, missingPackage: true };
|
|
67
|
+
}
|
|
63
68
|
if (ready(document) === true) {
|
|
64
69
|
return { document, attempts, waitedMs: now() - started };
|
|
65
70
|
}
|
package/lib/release.mjs
CHANGED
|
@@ -12,34 +12,35 @@ import path from 'node:path';
|
|
|
12
12
|
import { doctor } from './doctor.mjs';
|
|
13
13
|
import { addVersion, buildEntry, readIndex, writeIndex } from './index-file.mjs';
|
|
14
14
|
import { publishedAt, waitForVersion } from './registry.mjs';
|
|
15
|
+
import { defaultGh, mergePr, openIndexPr, waitForChecks } from './pr.mjs';
|
|
15
16
|
|
|
16
17
|
function git(dir, args) {
|
|
17
18
|
// stderr is dropped on purpose: `rev-parse` on a tag that does not exist yet is
|
|
18
19
|
// an expected miss, and git prints "fatal: ambiguous argument" for it, which
|
|
19
20
|
// reads like a failure in the middle of a successful release.
|
|
20
|
-
return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', '
|
|
21
|
+
return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function tagName(version, prefix = 'plugin-v') {
|
|
24
25
|
return `${prefix}${version}`;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
export function tagExists(dir, tag) {
|
|
28
|
+
export function tagExists(dir, tag, run) {
|
|
28
29
|
try {
|
|
29
|
-
git(dir, ['rev-parse', `refs/tags/${tag}`]);
|
|
30
|
+
(run ?? ((args) => git(dir, args)))(['rev-parse', `refs/tags/${tag}`]);
|
|
30
31
|
return true;
|
|
31
32
|
} catch {
|
|
32
33
|
return false;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
export function workingTreeIsClean(dir) {
|
|
37
|
-
return git(dir, ['status', '--porcelain']) === '';
|
|
37
|
+
export function workingTreeIsClean(dir, run) {
|
|
38
|
+
return (run ?? ((args) => git(dir, args)))(['status', '--porcelain']) === '';
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** The paths git would carry into the tag, so the message can name them. */
|
|
41
|
-
export function dirtyPaths(dir, limit = 3) {
|
|
42
|
-
const status = git(dir, ['status', '--porcelain']);
|
|
42
|
+
export function dirtyPaths(dir, limit = 3, run) {
|
|
43
|
+
const status = (run ?? ((args) => git(dir, args)))(['status', '--porcelain']);
|
|
43
44
|
if (status === '') return [];
|
|
44
45
|
const paths = status.split('\n').map((line) => line.slice(3).trim()).filter((line) => line !== '');
|
|
45
46
|
return paths.slice(0, limit);
|
|
@@ -80,6 +81,8 @@ export async function release(options) {
|
|
|
80
81
|
onChange,
|
|
81
82
|
} = options;
|
|
82
83
|
|
|
84
|
+
const gitRun = options.git ?? ((args) => git(dir, args));
|
|
85
|
+
|
|
83
86
|
let report;
|
|
84
87
|
try {
|
|
85
88
|
report = doctor(dir);
|
|
@@ -96,11 +99,11 @@ export async function release(options) {
|
|
|
96
99
|
const version = report.manifest.version;
|
|
97
100
|
const tag = tagName(version, tagPrefix);
|
|
98
101
|
|
|
99
|
-
if (tagExists(dir, tag) === true) {
|
|
102
|
+
if (tagExists(dir, tag, gitRun) === true) {
|
|
100
103
|
return { ok: false, stage: 'tag', failures: [{ check: 'tag', message: `${tag} already exists` }] };
|
|
101
104
|
}
|
|
102
|
-
if (workingTreeIsClean(dir) === false) {
|
|
103
|
-
const dirty = dirtyPaths(dir);
|
|
105
|
+
if (workingTreeIsClean(dir, gitRun) === false) {
|
|
106
|
+
const dirty = dirtyPaths(dir, 3, gitRun);
|
|
104
107
|
const listed = dirty.length > 0 ? ` (${dirty.join(', ')})` : '';
|
|
105
108
|
return {
|
|
106
109
|
ok: false,
|
|
@@ -109,13 +112,15 @@ export async function release(options) {
|
|
|
109
112
|
};
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
gitRun(['tag', tag]);
|
|
116
|
+
gitRun(['push', 'origin', tag]);
|
|
114
117
|
|
|
115
118
|
const waited = await waitForVersion(name, version, {
|
|
116
119
|
// Wait for the publish *time* as well: `versions` can appear a moment before
|
|
117
120
|
// `time`, and an index entry with a null timestamp fails the market checker.
|
|
118
121
|
requireTime: true,
|
|
122
|
+
// Forwarded so a caller (and the tests) can answer instead of npm.
|
|
123
|
+
fetch: options.fetch,
|
|
119
124
|
registry,
|
|
120
125
|
budgetMs,
|
|
121
126
|
intervalMs,
|
|
@@ -123,6 +128,18 @@ export async function release(options) {
|
|
|
123
128
|
now,
|
|
124
129
|
onWait: options.onWait,
|
|
125
130
|
});
|
|
131
|
+
if (waited.missingPackage === true) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
stage: 'registry',
|
|
135
|
+
failures: [
|
|
136
|
+
{
|
|
137
|
+
check: 'registry',
|
|
138
|
+
message: `the registry has no package named ${name} (tag ${tag} is pushed; publish it, then re-run)`,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
126
143
|
if (waited.timedOut === true) {
|
|
127
144
|
return {
|
|
128
145
|
ok: false,
|
|
@@ -141,14 +158,22 @@ export async function release(options) {
|
|
|
141
158
|
const indexPath = path.join(dir, indexFile);
|
|
142
159
|
let indexUpdate = null;
|
|
143
160
|
if (fs.existsSync(indexPath) === true) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
161
|
+
try {
|
|
162
|
+
const before = readIndex(indexPath);
|
|
163
|
+
const after = addVersion(before, name, entry);
|
|
164
|
+
if (after !== before) {
|
|
165
|
+
writeIndex(indexPath, after);
|
|
166
|
+
indexUpdate = { file: indexFile, version, entry };
|
|
167
|
+
if (typeof onChange === 'function') onChange(indexUpdate);
|
|
168
|
+
} else {
|
|
169
|
+
indexUpdate = { file: indexFile, version, entry, unchanged: true };
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return { ok: false, stage: 'index', failures: [{ check: 'index', message: error.message }] };
|
|
173
|
+
}
|
|
149
174
|
}
|
|
150
175
|
|
|
151
|
-
|
|
176
|
+
const result = {
|
|
152
177
|
ok: true,
|
|
153
178
|
stage: 'index',
|
|
154
179
|
tag,
|
|
@@ -157,4 +182,50 @@ export async function release(options) {
|
|
|
157
182
|
waitedMs: waited.waitedMs,
|
|
158
183
|
indexUpdate,
|
|
159
184
|
};
|
|
185
|
+
|
|
186
|
+
if (options.pr !== true || indexUpdate === null) return result;
|
|
187
|
+
|
|
188
|
+
// The index change is one line, but it still needs a branch, a pull request,
|
|
189
|
+
// a green CI and a merge; doing that by hand is where "forgot to open the PR"
|
|
190
|
+
// happens.
|
|
191
|
+
const ghRunner = options.gh ?? defaultGh(dir);
|
|
192
|
+
const pull = await openIndexPr({
|
|
193
|
+
dir,
|
|
194
|
+
indexFile,
|
|
195
|
+
version,
|
|
196
|
+
base: options.base ?? 'main',
|
|
197
|
+
branch: options.branch,
|
|
198
|
+
title: options.prTitle,
|
|
199
|
+
body: options.prBody,
|
|
200
|
+
git: options.git,
|
|
201
|
+
gh: ghRunner,
|
|
202
|
+
onStep: options.onStep,
|
|
203
|
+
});
|
|
204
|
+
result.pullRequest = pull;
|
|
205
|
+
if (pull.ok !== true) {
|
|
206
|
+
return { ...result, ok: false, stage: `pr:${pull.stage}`, failures: [{ check: 'pr', message: pull.reason }] };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (options.merge !== true) return result;
|
|
210
|
+
|
|
211
|
+
const checks = await waitForChecks({
|
|
212
|
+
url: pull.url,
|
|
213
|
+
gh: ghRunner,
|
|
214
|
+
budgetMs: options.checksBudgetMs,
|
|
215
|
+
intervalMs: options.checksIntervalMs,
|
|
216
|
+
sleep: options.sleep,
|
|
217
|
+
now: options.now,
|
|
218
|
+
onWait: options.onChecks,
|
|
219
|
+
});
|
|
220
|
+
result.checks = checks;
|
|
221
|
+
if (checks.ok !== true) {
|
|
222
|
+
return { ...result, ok: false, stage: 'checks', failures: [{ check: 'checks', message: checks.reason }] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const merged = await mergePr({ url: pull.url, gh: ghRunner, method: options.mergeMethod });
|
|
226
|
+
result.merged = merged;
|
|
227
|
+
if (merged.ok !== true) {
|
|
228
|
+
return { ...result, ok: false, stage: 'merge', failures: [{ check: 'merge', message: merged.reason }] };
|
|
229
|
+
}
|
|
230
|
+
return { ...result, stage: 'merged' };
|
|
160
231
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Preflight, release and test DeepSeek Harness plugins: checks what the host needs, waits for npm to actually serve the version, and keeps the market index honest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|