dsh-plugin-toolkit 0.3.4 → 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 CHANGED
@@ -1,5 +1,27 @@
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
+
16
+ ## 0.3.5
17
+
18
+ Four bugs found by running `release` for real against a throwaway package, instead of only `--dry-run`:
19
+
20
+ - The index entry came out with `publishTimestamp: null`. The registry was asked for the abbreviated packument (`application/vnd.npm.install-v1+json`), which has no `time` field at all — so the tool could see the version and still have no publish date. It now asks for the full document, and waits for `time[version]` before writing the entry.
21
+ - `git rev-parse` on a tag that does not exist yet printed `fatal: ambiguous argument …` in the middle of a successful release; stderr is now dropped for that call.
22
+ - "the working tree has uncommitted changes" now names up to three paths.
23
+ - A folder without `package.json` raised a stack trace instead of returning a preflight failure.
24
+
3
25
  ## 0.3.4
4
26
 
5
27
  - CI now runs on Windows as well as Linux and macOS; two tests assumed a POSIX temp path (`/tmp/…`) and were rewritten with `path.join`.
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. Print the exact `git add` / `gh pr create` commands for the index change.
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
- Steps that touch your repository are printed, never guessed at. `--dry-run` prints the whole sequence with the commands it would run.
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
 
@@ -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
- if (versions.some((existing) => existing.version === entry.version)) {
38
- throw new Error(`${name}@${entry.version} is already in the index`);
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
@@ -11,8 +11,12 @@ export const REGISTRY = 'https://registry.npmjs.org';
11
11
  export async function packument(name, options = {}) {
12
12
  const base = options.registry ?? REGISTRY;
13
13
  const request = options.fetch ?? fetch;
14
+ // The full document, not the abbreviated install one: the index entry needs
15
+ // `time[version]`, and `application/vnd.npm.install-v1+json` omits `time`
16
+ // entirely — which is why an abbreviated fetch can see a version and still
17
+ // have no publish date to write.
14
18
  const response = await request(`${base}/${name.replace('/', '%2F')}`, {
15
- headers: { accept: 'application/vnd.npm.install-v1+json' },
19
+ headers: { accept: 'application/json' },
16
20
  });
17
21
  if (response.status === 404) return null;
18
22
  if (response.ok === false) {
@@ -40,6 +44,12 @@ export function hasVersion(document, version) {
40
44
  * `onWait` is called after each miss so the CLI can report progress.
41
45
  */
42
46
  export async function waitForVersion(name, version, options = {}) {
47
+ // `requireTime` matters for the market index: `versions` can list a version
48
+ // before the registry has written its `time` entry, and an index entry with a
49
+ // null timestamp is rejected by the checker.
50
+ const ready = options.requireTime === true
51
+ ? (document) => hasVersion(document, version) === true && publishedAt(document, version) !== null
52
+ : (document) => hasVersion(document, version) === true;
43
53
  const budgetMs = options.budgetMs ?? 8 * 60 * 1000;
44
54
  const intervalMs = options.intervalMs ?? 10 * 1000;
45
55
  const now = options.now ?? Date.now;
@@ -50,7 +60,12 @@ export async function waitForVersion(name, version, options = {}) {
50
60
  for (;;) {
51
61
  attempts += 1;
52
62
  const document = await packument(name, options);
53
- if (hasVersion(document, version) === true) {
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
+ }
68
+ if (ready(document) === true) {
54
69
  return { document, attempts, waitedMs: now() - started };
55
70
  }
56
71
  if (now() - started >= budgetMs) {
package/lib/release.mjs CHANGED
@@ -12,26 +12,38 @@ 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
- return execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim();
18
+ // stderr is dropped on purpose: `rev-parse` on a tag that does not exist yet is
19
+ // an expected miss, and git prints "fatal: ambiguous argument" for it, which
20
+ // reads like a failure in the middle of a successful release.
21
+ return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
18
22
  }
19
23
 
20
24
  export function tagName(version, prefix = 'plugin-v') {
21
25
  return `${prefix}${version}`;
22
26
  }
23
27
 
24
- export function tagExists(dir, tag) {
28
+ export function tagExists(dir, tag, run) {
25
29
  try {
26
- git(dir, ['rev-parse', `refs/tags/${tag}`]);
30
+ (run ?? ((args) => git(dir, args)))(['rev-parse', `refs/tags/${tag}`]);
27
31
  return true;
28
32
  } catch {
29
33
  return false;
30
34
  }
31
35
  }
32
36
 
33
- export function workingTreeIsClean(dir) {
34
- return git(dir, ['status', '--porcelain']) === '';
37
+ export function workingTreeIsClean(dir, run) {
38
+ return (run ?? ((args) => git(dir, args)))(['status', '--porcelain']) === '';
39
+ }
40
+
41
+ /** The paths git would carry into the tag, so the message can name them. */
42
+ export function dirtyPaths(dir, limit = 3, run) {
43
+ const status = (run ?? ((args) => git(dir, args)))(['status', '--porcelain']);
44
+ if (status === '') return [];
45
+ const paths = status.split('\n').map((line) => line.slice(3).trim()).filter((line) => line !== '');
46
+ return paths.slice(0, limit);
35
47
  }
36
48
 
37
49
  /**
@@ -69,7 +81,16 @@ export async function release(options) {
69
81
  onChange,
70
82
  } = options;
71
83
 
72
- const report = doctor(dir);
84
+ const gitRun = options.git ?? ((args) => git(dir, args));
85
+
86
+ let report;
87
+ try {
88
+ report = doctor(dir);
89
+ } catch (error) {
90
+ // A folder without package.json is a preflight failure, not a crash: the
91
+ // command's job is to say what is wrong, and a stack trace is not an answer.
92
+ return { ok: false, stage: 'preflight', failures: [{ check: 'manifest', message: error.message }] };
93
+ }
73
94
  if (report.failures.length > 0) {
74
95
  return { ok: false, stage: 'preflight', failures: report.failures };
75
96
  }
@@ -78,21 +99,28 @@ export async function release(options) {
78
99
  const version = report.manifest.version;
79
100
  const tag = tagName(version, tagPrefix);
80
101
 
81
- if (tagExists(dir, tag) === true) {
102
+ if (tagExists(dir, tag, gitRun) === true) {
82
103
  return { ok: false, stage: 'tag', failures: [{ check: 'tag', message: `${tag} already exists` }] };
83
104
  }
84
- if (workingTreeIsClean(dir) === false) {
105
+ if (workingTreeIsClean(dir, gitRun) === false) {
106
+ const dirty = dirtyPaths(dir, 3, gitRun);
107
+ const listed = dirty.length > 0 ? ` (${dirty.join(', ')})` : '';
85
108
  return {
86
109
  ok: false,
87
110
  stage: 'git',
88
- failures: [{ check: 'git', message: 'the working tree has uncommitted changes' }],
111
+ failures: [{ check: 'git', message: `the working tree has uncommitted changes${listed}` }],
89
112
  };
90
113
  }
91
114
 
92
- git(dir, ['tag', tag]);
93
- git(dir, ['push', 'origin', tag]);
115
+ gitRun(['tag', tag]);
116
+ gitRun(['push', 'origin', tag]);
94
117
 
95
118
  const waited = await waitForVersion(name, version, {
119
+ // Wait for the publish *time* as well: `versions` can appear a moment before
120
+ // `time`, and an index entry with a null timestamp fails the market checker.
121
+ requireTime: true,
122
+ // Forwarded so a caller (and the tests) can answer instead of npm.
123
+ fetch: options.fetch,
96
124
  registry,
97
125
  budgetMs,
98
126
  intervalMs,
@@ -100,6 +128,18 @@ export async function release(options) {
100
128
  now,
101
129
  onWait: options.onWait,
102
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
+ }
103
143
  if (waited.timedOut === true) {
104
144
  return {
105
145
  ok: false,
@@ -118,14 +158,22 @@ export async function release(options) {
118
158
  const indexPath = path.join(dir, indexFile);
119
159
  let indexUpdate = null;
120
160
  if (fs.existsSync(indexPath) === true) {
121
- const before = readIndex(indexPath);
122
- const after = addVersion(before, name, entry);
123
- writeIndex(indexPath, after);
124
- indexUpdate = { file: indexFile, version, entry };
125
- if (typeof onChange === 'function') onChange(indexUpdate);
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
+ }
126
174
  }
127
175
 
128
- return {
176
+ const result = {
129
177
  ok: true,
130
178
  stage: 'index',
131
179
  tag,
@@ -134,4 +182,50 @@ export async function release(options) {
134
182
  waitedMs: waited.waitedMs,
135
183
  indexUpdate,
136
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' };
137
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.3.4",
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": {