@joshuafolkken/kit 1.1229.0 → 1.1240.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.
Files changed (67) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
  3. package/.claude/skills/workflow-commands/followup.md +5 -0
  4. package/.claude/skills/workflow-commands/kickoff.md +1 -0
  5. package/.claude/skills/workflow-commands/latest-gate.md +1 -1
  6. package/.github/workflows/publish.yml +2 -0
  7. package/README.md +30 -31
  8. package/dist/hooks/batch-guard.js +4 -4
  9. package/dist/hooks/{chunk-JUDKDCDP.js → chunk-4TZVB44A.js} +8 -8
  10. package/dist/hooks/{chunk-RNWHN5BP.js → chunk-F3ELQ2AF.js} +3 -3
  11. package/dist/hooks/{chunk-VC3PZPZO.js → chunk-GPTFMK5E.js} +1 -1
  12. package/dist/hooks/{chunk-FIVDHQGT.js → chunk-KHGB5S3Y.js} +1 -1
  13. package/dist/hooks/{chunk-NV2JRZZO.js → chunk-KHX4U3BG.js} +17 -2
  14. package/dist/hooks/{chunk-F56MFS4B.js → chunk-KRBERBBI.js} +3 -3
  15. package/dist/hooks/{chunk-TQNPDZNH.js → chunk-QVSWU24O.js} +4 -4
  16. package/dist/hooks/{chunk-55HHG5PX.js → chunk-WK2XSHSI.js} +284 -63
  17. package/dist/hooks/{chunk-5VMKCCC4.js → chunk-XZYKEM6J.js} +2 -2
  18. package/dist/hooks/{chunk-6HJJXRBX.js → chunk-ZQGD4GBY.js} +1 -1
  19. package/dist/hooks/codex-hook-adapter.js +10 -10
  20. package/dist/hooks/duplicate-read-guard.js +5 -5
  21. package/dist/hooks/format-edited.js +2 -2
  22. package/dist/hooks/inputs.json +172 -170
  23. package/dist/hooks/investigation-guard.js +5 -5
  24. package/dist/hooks/pretool-guard.js +9 -9
  25. package/dist/hooks/stop-guard.js +3 -17
  26. package/dist/josh.js +279 -63
  27. package/package.json +1 -1
  28. package/prompts/collaboration-workflow/issue-template.md +1 -0
  29. package/scripts/gate/core-admission.ts +162 -0
  30. package/scripts/gate/core-budget.ts +121 -67
  31. package/scripts/gate/gate-plan.ts +35 -2
  32. package/scripts/gate/gate-report.ts +28 -9
  33. package/scripts/gate/machine-capacity.ts +175 -0
  34. package/scripts/gate/verification-gate.ts +7 -2
  35. package/scripts/init/init.ts +5 -1
  36. package/scripts/init/package-manager-pin.ts +35 -0
  37. package/scripts/issue/issue-auto-ok.ts +31 -6
  38. package/scripts/issue/issue-file-cli.ts +31 -8
  39. package/scripts/issue/issue-file.ts +5 -0
  40. package/scripts/issue/issue-labels.ts +13 -0
  41. package/scripts/issue/issue-release-cli.ts +155 -0
  42. package/scripts/issue/issue-release.ts +58 -0
  43. package/scripts/josh/josh-command-types.ts +3 -0
  44. package/scripts/josh/josh-commands-development.ts +8 -0
  45. package/scripts/josh/josh-commands-issue.ts +8 -1
  46. package/scripts/josh/josh-commands-lane.ts +2 -1
  47. package/scripts/josh/josh-commands-maintenance.ts +2 -2
  48. package/scripts/josh/josh-logic.ts +4 -3
  49. package/scripts/lane/lane-cli.ts +9 -1
  50. package/scripts/lane/lane-leftover.ts +43 -29
  51. package/scripts/lane/lane-open.ts +5 -5
  52. package/scripts/lane/lane-paths.ts +13 -0
  53. package/scripts/lane/lane-registry.ts +11 -0
  54. package/scripts/lane/lane-stray.ts +140 -0
  55. package/scripts/release/github-release-environment.ts +1 -0
  56. package/scripts/release/github-release-runs.ts +115 -0
  57. package/scripts/release/github-release.ts +13 -67
  58. package/scripts/run/cut/run-cut-cli.ts +16 -3
  59. package/scripts/run/event/run-event-render.ts +1 -0
  60. package/scripts/run/event/run-event-stream.ts +4 -0
  61. package/scripts/run/run-step.ts +5 -2
  62. package/scripts/run/wake/run-wake-cli.ts +2 -3
  63. package/scripts/run/wake/run-wake-describe.ts +56 -5
  64. package/scripts/run/wake/run-wake-loop.ts +5 -13
  65. package/scripts/run/wake/run-wake.ts +15 -64
  66. package/scripts/version/latest-corepack.ts +109 -78
  67. package/templates/workflows/github-release.yml +4 -0
@@ -126,14 +126,6 @@ interface RunWake {
126
126
  // session that is merely slow is still doing the run's work, and a supervisor that killed what it
127
127
  // could not account for would destroy exactly the work it exists to keep going.
128
128
  woke_pid?: number | undefined
129
- // When the wait on a still-running predecessor began. **It is its own field rather than a second
130
- // use of `woke_at`, and that is the whole of the bound working as described.** Marked on `woke_at`,
131
- // the first pass of a wait was the last one that could see the predecessor exit — every later pass
132
- // read the mark as an outstanding wake and answered `pending` — so a predecessor that ended two
133
- // seconds after the hold still cost the full grace window on a supervisor whose point is not to
134
- // stall. Kept apart, the predecessor's liveness is read on every pass and the mark only bounds how
135
- // long the wait may last.
136
- held_at?: string | undefined
137
129
  // The transcript ids of every session this supervisor forced with `--session-id`
138
130
  // (joshuafolkken/kit#2407). `josh time --run` attributes a whiff only to a session in this list, so
139
131
  // an unrelated read-only session that moved while the supervisor was alive is no longer counted
@@ -164,7 +156,6 @@ type WakeDecision =
164
156
  | { kind: 'idle' }
165
157
  | { kind: 'wait' }
166
158
  | { kind: 'pending' }
167
- | { kind: 'hold' }
168
159
  | { kind: 'failed' }
169
160
  | { kind: 'stop'; reason: WakeStopReason }
170
161
 
@@ -172,16 +163,16 @@ interface WakeDecisionInput {
172
163
  read: CarryRead
173
164
  woke_at: string | undefined
174
165
  attempts: number
175
- // Whether the process named on the carry record is still running. **A cut is the run's last write,
176
- // not the moment its process is gone** — so a supervisor that woke the instant it saw the hand-off
177
- // could have the new session answered `busy`, because `run-carry.ts` → `classify_claim` tests
178
- // `is_foreign_live_owner` before it tests the hand-off. Answered `busy`, the woken session stops
179
- // without claiming anything, and the supervisor would eventually call its own wake a failure and end
180
- // the run. So the predecessor is waited out first.
166
+ // Whether the process named on the carry record is still running — read only for a record no cut
167
+ // handed off, where it tells a session at work from a crashed one (`decide_not_handed_off`).
168
+ //
169
+ // **A handed-off record is launched from whether its cutting process lives or not**
170
+ // (joshuafolkken/kit#3363). The supervisor used to wait that process out, for up to the grace
171
+ // window, because `run-carry.ts` → `classify_claim` once tested liveness before the hand-off and
172
+ // would have answered the successor `busy`. joshuafolkken/kit#1935 put the hand-off first, so the
173
+ // wait guarded nothing — and an interactive session outlives its own cut, so every cut paid the
174
+ // whole ten minutes before the driver started.
181
175
  is_owner_live: boolean
182
- // When the wait on that predecessor began, so the wait can be bounded without spending the mark
183
- // that measures a launch.
184
- held_at: string | undefined
185
176
  // **Whether a woken session would find anything to do** (joshuafolkken/kit#2417). Before this the
186
177
  // decision read the carry record alone, so a session was launched whether or not the backlog held a
187
178
  // runnable issue or a lane was free — and a session that found nothing ended without touching
@@ -198,7 +189,6 @@ const WAKE_DECISION: WakeDecision = { kind: 'wake' }
198
189
  const IDLE_DECISION: WakeDecision = { kind: 'idle' }
199
190
  const WAIT_DECISION: WakeDecision = { kind: 'wait' }
200
191
  const PENDING_DECISION: WakeDecision = { kind: 'pending' }
201
- const HOLD_DECISION: WakeDecision = { kind: 'hold' }
202
192
  const FAILED_DECISION: WakeDecision = { kind: 'failed' }
203
193
 
204
194
  // The three carry reads that are not `carried`, each mapped to why the supervisor stops. `none` is the
@@ -220,7 +210,6 @@ const run_wake_schema = process_owner_schema.extend({
220
210
  // reads as "no attempt has been made yet" and would restart the retry count on every pass.
221
211
  attempts: z.number().optional(),
222
212
  woke_pid: z.number().optional(),
223
- held_at: z.string().optional(),
224
213
  spawned: z.array(z.string()).optional(),
225
214
  idle_since: z.string().optional(),
226
215
  profile: agent_role_profile.PROFILE_SCHEMA.optional(),
@@ -279,32 +268,10 @@ function decide_not_handed_off(input: WakeDecisionInput): WakeDecision {
279
268
  return decide_launch(input)
280
269
  }
281
270
 
282
- // Only before the first launch for this cut. Once a wake is out, the record's owner is whatever the
283
- // woken session declares, and waiting on it again would stall the grace window indefinitely.
284
- //
285
- // **The wait is bounded, and the bound is not decoration.** The owner is the agent process of the
286
- // session that cut, and an interactive one often outlives its own cut — waited on without a bound, the
287
- // supervisor would pend every interval for the record's whole life, wake nothing, and end on `expired`,
288
- // which sends no warning. That is a silent overnight failure, which is worse than the `busy` race this
289
- // wait exists to avoid: a `busy` merely costs one attempt, and attempts are retried. So the wait is
290
- // marked when it starts and expires into an ordinary wake through the same grace window everything
291
- // else uses.
292
- //
293
- // **And the wait ends the moment the predecessor does, because its liveness is read on every pass.**
294
- // The bound is the ceiling on the wait, never its length: a predecessor that exits two seconds after
295
- // the hold is waited two seconds. That is what `held_at` is a separate field for — marked on `woke_at`,
296
- // this test could not fire a second time, so every wait cost the whole ceiling.
297
- function is_predecessor_exiting(input: WakeDecisionInput): boolean {
298
- if (input.woke_at !== undefined || !input.is_owner_live) return false
299
-
300
- return input.held_at === undefined || !is_overdue(input.held_at, input.now)
301
- }
302
-
303
- // The whole policy, in four lines. Nothing else in this module decides whether to wake.
271
+ // The whole policy, in three lines. Nothing else in this module decides whether to wake.
304
272
  function decide(input: WakeDecisionInput): WakeDecision {
305
273
  if (input.read.kind !== 'carried') return { kind: 'stop', reason: STOP_REASONS[input.read.kind] }
306
274
  if (input.read.carry.is_handed_off !== true) return decide_not_handed_off(input)
307
- if (is_predecessor_exiting(input)) return HOLD_DECISION
308
275
 
309
276
  return decide_launch(input)
310
277
  }
@@ -367,10 +334,9 @@ function is_supervisor_live(wake: RunWake): boolean {
367
334
  // inside the grace window would otherwise see no wake mark, launch a second session for the *same*
368
335
  // cut, count it as another cut served, and leave two sessions racing for one record. The invocation
369
336
  // has to match — a different one is a different run, and its state is not this one's.
370
- // **`woke_pid` and `held_at` come across too.** Without the first, a restart inside the grace window
371
- // that then spends its retries reports "the last one was none" in the warning — the one fact the
372
- // warning exists to hand the person, dropped exactly where it is needed. Without the second, a restart
373
- // restarts the bounded wait, which is the ceiling sliding by another road.
337
+ // **`woke_pid` comes across too.** Without it, a restart inside the grace window that then spends its
338
+ // retries reports "the last one was none" in the warning — the one fact the warning exists to hand the
339
+ // person, dropped exactly where it is needed.
374
340
  function carried_state(existing: RunWake | undefined, invocation: string): Partial<RunWake> {
375
341
  if (existing?.invocation !== invocation) return { woke: NO_WAKES }
376
342
 
@@ -379,7 +345,6 @@ function carried_state(existing: RunWake | undefined, invocation: string): Parti
379
345
  woke_at: existing.woke_at,
380
346
  attempts: existing.attempts,
381
347
  woke_pid: existing.woke_pid,
382
- held_at: existing.held_at,
383
348
  // **`spawned` carries across too, or a restart mid-run would lose the ids of every session it
384
349
  // already started** (joshuafolkken/kit#2407) — and `josh time --run`, reading the record after
385
350
  // the restart, would under-count the whiffs and read the loss as a saving.
@@ -447,24 +412,12 @@ function count_wake(wake: RunWake, now: Date, pid: number, session_id: string):
447
412
  spawned,
448
413
  woke_at,
449
414
  woke_pid: pid,
450
- held_at: undefined,
451
415
  idle_since: undefined,
452
416
  }
453
417
  }
454
418
 
455
- // Starts the clock on a wait without launching anything. `attempts` is deliberately untouched, so the
456
- // bounded wait costs no retry: what it buys is that the wait expires into an ordinary wake instead of
457
- // running until the carry record does.
458
- //
459
- // **Marked once, so the ceiling does not slide.** A mark rewritten on every pass would measure from the
460
- // latest pass rather than from the start of the wait, and a predecessor that never exits would then be
461
- // waited on for ever — the unbounded wait this bound exists to prevent, arrived at by another road.
462
- function mark_wait(wake: RunWake, now: Date): RunWake {
463
- return wake.held_at === undefined ? { ...wake, held_at: now.toISOString() } : wake
464
- }
465
-
466
- // Starts the idle stretch a deferred launch is bounded by (joshuafolkken/kit#2417). Marked once, for
467
- // the reason `mark_wait` is: rewritten each pass, the ceiling would slide and never be reached.
419
+ // Starts the idle stretch a deferred launch is bounded by (joshuafolkken/kit#2417). Marked once:
420
+ // rewritten each pass, the ceiling would slide and never be reached.
468
421
  function mark_idle(wake: RunWake, now: Date): RunWake {
469
422
  return wake.idle_since === undefined ? { ...wake, idle_since: now.toISOString() } : wake
470
423
  }
@@ -489,7 +442,6 @@ function count_claim(wake: RunWake): RunWake {
489
442
  woke,
490
443
  woke_at: undefined,
491
444
  attempts: undefined,
492
- held_at: undefined,
493
445
  idle_since: undefined,
494
446
  }
495
447
  }
@@ -589,7 +541,6 @@ const run_wake = {
589
541
  fresh_wake,
590
542
  is_supervisor_live,
591
543
  mark_idle,
592
- mark_wait,
593
544
  parse_wake,
594
545
  read_own_wake,
595
546
  read_wake,
@@ -30,8 +30,12 @@
30
30
  * manifest that arrived with the two fields out of step kept the pnpm dual-declaration
31
31
  * warning forever. The alignment is idempotent, so running it unconditionally is free.
32
32
  *
33
- * Registry and pnpm failures stay non-fatal: they are logged and swallowed (exit 0)
34
- * so the rest of the `josh latest` chain (`latest:update`, `audit`) keeps running.
33
+ * A failed bump exits non-zero with its cause, the manifest restored (#3361). It used to be
34
+ * swallowed as a skip, so `latest:scope --record` marked the run fresh and a pnpm that could not
35
+ * self-update (the Corepack shim refuses it) stayed pinned silently. `josh latest` runs this step
36
+ * last, right before the record, so the dependency update and the audit still finish and only the
37
+ * record is withheld. A registry answer not newer than the pin, or nothing aged past the
38
+ * quarantine window yet, remains a non-fatal skip.
35
39
  *
36
40
  * Usage: tsx scripts/version/latest-corepack.ts
37
41
  */
@@ -54,9 +58,19 @@ const WORKSPACE_PATH = 'pnpm-workspace.yaml'
54
58
  const SHA512_BYTES = 64
55
59
  const INTEGRITY_RE = /^sha512-([A-Za-z0-9+/]+={0,2})$/u
56
60
  const PACKAGE_MANAGER_VALUE_RE = /("packageManager"\s*:\s*")pnpm@[^"]+(")/u
61
+
62
+ // `target` is undefined both when the registry did not answer and when it answered but nothing has
63
+ // aged past the quarantine window yet; only the first is a failure (#3361).
64
+ interface TargetResolution {
65
+ target: string | undefined
66
+ is_registry_reachable: boolean
67
+ }
68
+
57
69
  const PNPM_DEVELOPMENT_MANAGER_SCHEMA = z.object({ name: z.literal('pnpm'), version: z.string() })
58
70
  const DEV_ENGINES_SCHEMA = z.object({ packageManager: PNPM_DEVELOPMENT_MANAGER_SCHEMA })
59
71
  const DEV_ENGINES_PNPM_SCHEMA = z.object({ devEngines: DEV_ENGINES_SCHEMA.optional() })
72
+ const COREPACK_REMEDY =
73
+ 'pnpm runs through the Corepack shim, where pnpm 11+ refuses self-update (ERR_PNPM_CANT_SELF_UPDATE_IN_COREPACK). Run `corepack disable pnpm`, install a standalone pnpm (https://pnpm.io/installation), then rerun josh latest.'
60
74
 
61
75
  function extract_development_engines_version(package_json_content: string): string | undefined {
62
76
  const parsed = DEV_ENGINES_PNPM_SCHEMA.safeParse(
@@ -128,22 +142,33 @@ function query_release_times(): Record<string, string> | undefined {
128
142
  return extract_times_json(result.stdout)
129
143
  }
130
144
 
145
+ function to_resolution(
146
+ version: string | undefined,
147
+ is_registry_reachable: boolean,
148
+ ): TargetResolution {
149
+ const target = version === undefined ? undefined : `${TARGET_PREFIX}${version}`
150
+
151
+ return { target, is_registry_reachable }
152
+ }
153
+
131
154
  // Ask the registry for the newest pnpm release on the pinned major that has aged past the
132
- // quarantine window. Returns undefined when the query fails or nothing qualifies yet.
133
- function query_major_latest_version(major: string): string | undefined {
155
+ // quarantine window.
156
+ function resolve_major_target(major: string): TargetResolution {
134
157
  const times = query_release_times()
135
- if (times === undefined) return undefined
158
+ if (times === undefined) return to_resolution(undefined, false)
136
159
 
137
160
  // The project's own `pnpm-workspace.yaml`, deliberately not the upward walk the version check
138
161
  // uses: `josh latest` reads `package.json` and writes the pnpm pin relative to the working
139
162
  // directory, so it has no subdirectory case — and honouring an ancestor's policy here could freeze
140
163
  // pnpm bumps in a project that declares none (joshuafolkken/kit#808).
141
- return release_age.select_aged_version(
164
+ const version = release_age.select_aged_version(
142
165
  times,
143
166
  major,
144
167
  release_age.read_minimum_release_age(WORKSPACE_PATH),
145
168
  Date.now(),
146
169
  )
170
+
171
+ return to_resolution(version, true)
147
172
  }
148
173
 
149
174
  function parse_latest_version(stdout: string): string | undefined {
@@ -163,24 +188,40 @@ function query_latest_version(): string | undefined {
163
188
  return parse_latest_version(result.stdout)
164
189
  }
165
190
 
166
- // The value handed to pnpm self-update: an exact registry-resolved version on the pinned
167
- // major, the latest exact version when no major can be read from package.json, or undefined when
168
- // the registry could not answer (the caller skips non-fatally).
169
- function resolve_corepack_target(major: string | undefined): string | undefined {
170
- const version = major === undefined ? query_latest_version() : query_major_latest_version(major)
171
- if (version === undefined) return undefined
191
+ // The target handed to pnpm self-update: an exact registry-resolved version on the pinned major, or
192
+ // the latest exact version when no major can be read from package.json. The latest-version query
193
+ // has no quarantine selection, so resolving nothing there always means the registry did not answer.
194
+ function resolve_target(major: string | undefined): TargetResolution {
195
+ if (major !== undefined) return resolve_major_target(major)
196
+ const version = query_latest_version()
197
+
198
+ return to_resolution(version, version !== undefined)
199
+ }
172
200
 
173
- return `pnpm@${version}`
201
+ function resolve_corepack_target(major: string | undefined): string | undefined {
202
+ return resolve_target(major).target
174
203
  }
175
204
 
176
- // Non-fatal skip message shared by both skip paths: keep the josh latest chain
177
- // (latest:update, audit) running instead of aborting on a bump failure.
205
+ // Non-fatal skip message for a registry answer with nothing to adopt — nothing to bump, nothing failed.
178
206
  function warn_skip(reason: string): void {
179
- console.warn(`⚠ Skipped pnpm bump (${reason}); the chain continues.`)
207
+ console.warn(`⚠ Skipped pnpm bump (${reason}).`)
208
+ }
209
+
210
+ // Every failure path ends here, so the exit status the `josh latest` chain stops on is one value.
211
+ function fail_bump(reason: string): number {
212
+ console.error(
213
+ `✖ pnpm bump failed (${reason}); the run is not recorded, so the next josh latest retries it.`,
214
+ )
215
+
216
+ return FAILURE_EXIT_CODE
180
217
  }
181
218
 
182
- function warn_unresolved(major: string | undefined = ''): void {
183
- warn_skip(`no pnpm ${major} release resolvable from the registry`)
219
+ // Corepack exports COREPACK_ROOT to the package manager it launches, and pnpm 11+ refuses
220
+ // `self-update` under it (ERR_PNPM_CANT_SELF_UPDATE_IN_COREPACK), so the remedy is named there.
221
+ function fail_self_update(status: number, environment: NodeJS.ProcessEnv = process.env): number {
222
+ if (environment['COREPACK_ROOT'] !== undefined) console.error(COREPACK_REMEDY)
223
+
224
+ return fail_bump(`pnpm self-update exited ${String(status)}`)
184
225
  }
185
226
 
186
227
  function run_pnpm_update(target: string): number {
@@ -193,17 +234,6 @@ function run_pnpm_update(target: string): number {
193
234
  return result.exitCode ?? FAILURE_EXIT_CODE
194
235
  }
195
236
 
196
- // The target is an already-resolved exact version, so a non-zero status here is a
197
- // genuine pnpm or network failure; a later run retries. Returns whether a skip
198
- // happened.
199
- function did_warn_skip(status: number): boolean {
200
- if (status === 0) return false
201
-
202
- warn_skip(`pnpm self-update exited ${String(status)}`)
203
-
204
- return true
205
- }
206
-
207
237
  // Realign `devEngines.packageManager.version` with the `packageManager` pin so the two
208
238
  // fields keep matching (pnpm suppresses the dual-declaration warning only on an exact
209
239
  // match). Runs on every path, not just after a successful bump: a repository that arrives
@@ -224,7 +254,7 @@ function restore_package_json(
224
254
  package_json_path: string = PACKAGE_JSON_PATH,
225
255
  ): void {
226
256
  writeFileSync(package_json_path, content)
227
- console.info('✔ Restored package.json pin (pnpm bump skipped)')
257
+ console.info('✔ Restored package.json pin (pnpm bump failed)')
228
258
  }
229
259
 
230
260
  function decode_integrity(encoded: string): string | undefined {
@@ -292,35 +322,19 @@ function notify_skipped_bump(target: string, pinned_version: string): void {
292
322
  warn_skip(`registry answered ${target}, below the pinned ${pinned_version}`)
293
323
  }
294
324
 
295
- // Resolve the pnpm target with the pin floor applied. Undefined means the bump was
296
- // skipped and the reason has already been logged.
297
- function resolve_floored_target(original: string, major: string | undefined): string | undefined {
298
- const target = resolve_corepack_target(major)
299
-
300
- if (target === undefined) {
301
- warn_unresolved(major)
302
-
303
- return undefined
304
- }
305
-
306
- const pinned_version = extract_pinned_version(original)
307
- if (!is_target_not_newer_than_pin(target, pinned_version)) return target
308
-
309
- notify_skipped_bump(target, pinned_version ?? '')
310
-
311
- return undefined
312
- }
313
-
314
- function restore_after_update(original: string, target: string, integrity: string): void {
325
+ function restore_after_update(original: string, target: string, integrity: string): number {
315
326
  try {
316
327
  restore_integrity(target, integrity)
328
+
329
+ return 0
317
330
  } catch {
318
331
  restore_package_json(original)
319
- warn_skip('pnpm self-update wrote an unexpected pin')
332
+
333
+ return fail_bump('pnpm self-update wrote an unexpected pin')
320
334
  }
321
335
  }
322
336
 
323
- function pin_unpinned_manifest(original: string, target: string, integrity: string): void {
337
+ function pin_unpinned_manifest(original: string, target: string, integrity: string): number {
324
338
  const manifest = z.record(z.string(), z.unknown()).parse(json_value.parse_or_undefined(original))
325
339
  const pinned = JSON.stringify(
326
340
  { ...manifest, packageManager: `${target}+${integrity}` },
@@ -334,53 +348,71 @@ function pin_unpinned_manifest(original: string, target: string, integrity: stri
334
348
  )
335
349
 
336
350
  const result = execaSync('pnpm', ['--version'], { reject: false })
337
- if (result.exitCode === 0 && result.stdout.trim() === target.slice(TARGET_PREFIX.length)) return
351
+ if (result.exitCode === 0 && result.stdout.trim() === target.slice(TARGET_PREFIX.length)) return 0
338
352
 
339
353
  restore_package_json(original)
340
- warn_skip('the newly pinned pnpm version could not start')
341
- }
342
354
 
343
- function update_pinned_manifest(original: string, target: string, integrity: string): void {
344
- const is_skipped = did_warn_skip(run_pnpm_update(target))
355
+ return fail_bump('the newly pinned pnpm version could not start')
356
+ }
345
357
 
346
- if (is_skipped) {
347
- restore_package_json(original)
358
+ // The target is an already-resolved exact version, so a non-zero status here is a genuine
359
+ // pnpm, Corepack or network failure.
360
+ function update_pinned_manifest(original: string, target: string, integrity: string): number {
361
+ const status = run_pnpm_update(target)
362
+ if (status === 0) return restore_after_update(original, target, integrity)
348
363
 
349
- return
350
- }
364
+ restore_package_json(original)
351
365
 
352
- restore_after_update(original, target, integrity)
366
+ return fail_self_update(status)
353
367
  }
354
368
 
355
369
  // Query integrity before mutating the manifest; self-update writes both pins without it.
356
- function bump_package_manager(original: string, target: string): void {
370
+ function bump_package_manager(original: string, target: string): number {
357
371
  const integrity = query_integrity(target)
372
+ if (integrity === undefined) return fail_bump(`no integrity for ${target}`)
373
+ if (!PACKAGE_MANAGER_RE.test(original)) return pin_unpinned_manifest(original, target, integrity)
358
374
 
359
- if (integrity === undefined) {
360
- warn_skip(`no integrity for ${target}`)
375
+ return update_pinned_manifest(original, target, integrity)
376
+ }
361
377
 
362
- return
363
- }
378
+ // An unreachable registry fails, so the unrecorded run is retried. A reachable one with nothing
379
+ // aged past the quarantine window is the kit#768 skip — like a not-newer answer, there is nothing
380
+ // to adopt yet, and failing would stall every run for up to the whole window.
381
+ function report_unresolved(major: string | undefined, is_registry_reachable: boolean): number {
382
+ const label = `pnpm ${major ?? ''}`.trimEnd()
383
+ if (!is_registry_reachable) return fail_bump(`the registry did not answer for ${label}`)
364
384
 
365
- if (!PACKAGE_MANAGER_RE.test(original)) {
366
- pin_unpinned_manifest(original, target, integrity)
385
+ warn_skip(`no ${label} release has aged past the quarantine window yet`)
367
386
 
368
- return
387
+ return 0
388
+ }
389
+
390
+ // Resolve the pnpm target with the pin floor applied, then bump.
391
+ function bump_to_registry_target(original: string, major: string | undefined): number {
392
+ const { target, is_registry_reachable } = resolve_target(major)
393
+ if (target === undefined) return report_unresolved(major, is_registry_reachable)
394
+
395
+ const pinned_version = extract_pinned_version(original)
396
+
397
+ if (!is_target_not_newer_than_pin(target, pinned_version)) {
398
+ return bump_package_manager(original, target)
369
399
  }
370
400
 
371
- update_pinned_manifest(original, target, integrity)
401
+ notify_skipped_bump(target, pinned_version ?? '')
402
+
403
+ return 0
372
404
  }
373
405
 
374
- function main(): void {
406
+ function main(): number {
375
407
  const original = readFileSync(PACKAGE_JSON_PATH, 'utf8')
376
- const major = extract_pnpm_major(original)
377
- const target = resolve_floored_target(original, major)
378
- if (target !== undefined) bump_package_manager(original, target)
408
+ const status = bump_to_registry_target(original, extract_pnpm_major(original))
379
409
 
380
410
  sync_development_engines()
411
+
412
+ return status
381
413
  }
382
414
 
383
- if (process.argv[1] === fileURLToPath(import.meta.url)) main()
415
+ if (process.argv[1] === fileURLToPath(import.meta.url)) process.exitCode = main()
384
416
 
385
417
  const latest_corepack = {
386
418
  extract_pnpm_major,
@@ -388,12 +420,11 @@ const latest_corepack = {
388
420
  is_target_not_newer_than_pin,
389
421
  notify_skipped_bump,
390
422
  extract_times_json,
391
- query_major_latest_version,
392
423
  resolve_corepack_target,
393
424
  run_pnpm_update,
394
425
  query_integrity,
395
426
  restore_integrity,
396
- did_warn_skip,
427
+ fail_self_update,
397
428
  sync_development_engines,
398
429
  restore_package_json,
399
430
  main,
@@ -7,6 +7,9 @@ name: GitHub Release
7
7
  # started by the GITHUB_TOKEN never fires one (joshuafolkken/kit#3138). kit's own release is the
8
8
  # `create-release` job of its publish.yml, which runs the same `josh release:github`, so this file is
9
9
  # distributed but never runs in kit.
10
+ # The title names the tag, so a later tag's release can tell this run is still at work on it.
11
+ run-name: GitHub Release ${{ github.event.client_payload.tag }}
12
+
10
13
  on:
11
14
  repository_dispatch:
12
15
  types: [new-tag-created]
@@ -67,3 +70,4 @@ jobs:
67
70
  RELEASE_TAG: ${{ steps.release.outputs.tag }}
68
71
  RELEASE_WORKFLOW: ${{ steps.release.outputs.workflow }}
69
72
  RELEASE_AWAIT_PUBLISH: ${{ steps.release.outputs.await }}
73
+ RELEASE_RUN_WORKFLOW: github-release.yml