@joshuafolkken/kit 1.883.0 → 1.921.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 (112) hide show
  1. package/.claude/skills/workflow-commands/fullrun-steps.md +4 -2
  2. package/.claude/skills/workflow-commands/halfrun.md +3 -1
  3. package/.claude/skills/workflow-commands/kickoff.md +5 -3
  4. package/.claude/skills/workflow-commands/observation-filing.md +3 -1
  5. package/.claude/skills/workflow-commands/prerequisite.md +3 -1
  6. package/.claude/skills/workflow-commands/target-repository.md +1 -1
  7. package/.github/pull_request_template.md +12 -0
  8. package/.github/release.yml +3 -0
  9. package/.github/workflows/ci.yml +8 -0
  10. package/.github/workflows/production.yml +10 -1
  11. package/.github/workflows/publish.yml +72 -2
  12. package/CLAUDE.md +2 -0
  13. package/dist/CLAUDE.md +2 -0
  14. package/dist/CLAUDE.static.md +8 -0
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-36UE53LS.js → chunk-3HZ5MJIW.js} +2 -2
  17. package/dist/hooks/chunk-3V37IID6.js +82 -0
  18. package/dist/hooks/{chunk-FRJKVSK3.js → chunk-77RLKZ5V.js} +2 -2
  19. package/dist/hooks/{chunk-VGYSA2NT.js → chunk-LC765SUA.js} +16 -2
  20. package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NWD47MW3.js} +3 -3
  21. package/dist/hooks/{chunk-4OXFU3MK.js → chunk-O2FBZPSW.js} +619 -156
  22. package/dist/hooks/{chunk-6MQCXEEA.js → chunk-OLTAGGHH.js} +25 -88
  23. package/dist/hooks/{chunk-FNFOLFFV.js → chunk-OYU6KPRA.js} +1 -1
  24. package/dist/hooks/{chunk-PRDCNGA6.js → chunk-UANRLZV7.js} +4 -4
  25. package/dist/hooks/{chunk-264QY6KS.js → chunk-UULARQ5Q.js} +23 -1
  26. package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-XMR3BS35.js} +3 -3
  27. package/dist/hooks/codex-hook-adapter.js +11 -10
  28. package/dist/hooks/duplicate-read-guard.js +5 -5
  29. package/dist/hooks/format-edited.js +3 -2
  30. package/dist/hooks/investigation-guard.js +5 -5
  31. package/dist/hooks/pretool-guard.js +10 -9
  32. package/dist/hooks/stop-guard.js +3 -3
  33. package/dist/josh.js +155 -12
  34. package/package.json +70 -19
  35. package/prettier/index.js +3 -8
  36. package/prettier/static.d.ts +3 -0
  37. package/prettier/static.js +10 -0
  38. package/prompts/collaboration-workflow/issue-template.md +11 -4
  39. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  40. package/prompts/collaboration-workflow/wip-cap.md +2 -2
  41. package/prompts/review.md +2 -2
  42. package/prompts/testing-guide.md +1 -0
  43. package/scripts/build/build-claude-md.ts +6 -1
  44. package/scripts/ci/pr-classification.ts +114 -0
  45. package/scripts/document/document-byte-budget.ts +3 -2
  46. package/scripts/gate/gate-report.ts +2 -0
  47. package/scripts/gate/gate-steps.ts +50 -0
  48. package/scripts/gate/project-checks.ts +121 -0
  49. package/scripts/gate/verification-gate.ts +14 -43
  50. package/scripts/git/git-common-directory.ts +9 -1
  51. package/scripts/git/git-gh-pr-rollup.ts +29 -1
  52. package/scripts/git/git-gh-pr.ts +61 -1
  53. package/scripts/git/git-pr.ts +62 -13
  54. package/scripts/git/issue-labels.ts +9 -0
  55. package/scripts/init/distributed-paths.ts +9 -5
  56. package/scripts/init/hook-command-bootstrap.ts +40 -8
  57. package/scripts/init/hook-command-rewrite.ts +7 -3
  58. package/scripts/init/init-actions.ts +94 -7
  59. package/scripts/init/init-ai-copy.ts +104 -19
  60. package/scripts/init/init-static.ts +52 -0
  61. package/scripts/init/init.ts +97 -30
  62. package/scripts/init/project-profile-cli.ts +14 -0
  63. package/scripts/init/project-profile.ts +149 -0
  64. package/scripts/init/start.ts +30 -0
  65. package/scripts/issue/issue-bug-label.ts +13 -0
  66. package/scripts/issue/issue-classification.ts +74 -0
  67. package/scripts/issue/issue-lint-cli.ts +10 -3
  68. package/scripts/issue/markdown-section.ts +38 -4
  69. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +27 -1
  70. package/scripts/josh/josh-command-map.ts +3 -0
  71. package/scripts/josh/josh-commands-development.ts +6 -0
  72. package/scripts/josh/josh-commands-project.ts +14 -1
  73. package/scripts/josh/josh-logic.ts +13 -0
  74. package/scripts/josh/run-github-prerequisite.ts +32 -0
  75. package/scripts/lane/lane-await.ts +8 -1
  76. package/scripts/lane/openai-lane-ship.ts +30 -0
  77. package/scripts/lane/openai-lane-supervisor.ts +35 -8
  78. package/scripts/lane/openai-review-broker.ts +260 -0
  79. package/scripts/lint/cspell-cached.ts +21 -0
  80. package/scripts/lint/lint-parallel.ts +44 -1
  81. package/scripts/observations/observations-flush.ts +1 -1
  82. package/scripts/package/production-dependency-graph.ts +68 -0
  83. package/scripts/package/verify-optional-eslint-install.ts +62 -0
  84. package/scripts/propagate/propagate-run.ts +6 -0
  85. package/scripts/propagate/propagate-steps.ts +2 -0
  86. package/scripts/release/github-release-cli.ts +11 -0
  87. package/scripts/release/github-release.ts +220 -0
  88. package/scripts/release/publish-tag-cli.ts +8 -0
  89. package/scripts/release/publish-tag.ts +11 -0
  90. package/scripts/release/release-publish.ts +7 -2
  91. package/scripts/rules/delivered-rules.ts +2 -0
  92. package/scripts/rules/issue-bug-label.ts +85 -0
  93. package/scripts/rules/issue-filing-args.ts +50 -0
  94. package/scripts/run/run-merge-cli.ts +19 -0
  95. package/scripts/run/run-merge.ts +9 -1
  96. package/scripts/run/run-ship-cli.ts +20 -0
  97. package/scripts/run/run-ship-detach.ts +180 -14
  98. package/scripts/run/run-ship-review-steps.ts +45 -25
  99. package/scripts/run/run-wake-session.ts +3 -1
  100. package/scripts/test/pilot-files.ts +0 -2
  101. package/scripts/test/skip-marker.ts +3 -0
  102. package/scripts/test/test-declared-changed.ts +2 -1
  103. package/scripts/test/test-declared-logic.ts +15 -10
  104. package/scripts/test/test-declared.ts +20 -8
  105. package/scripts/test/test-red-logic.ts +3 -3
  106. package/scripts/test/test-unit-guard.ts +24 -12
  107. package/scripts/version/version-check-arguments.ts +14 -0
  108. package/scripts/version/version-check.ts +10 -3
  109. package/templates/CLAUDE.static.md +8 -0
  110. package/templates/cursorrules.static +1 -0
  111. package/templates/prettierignore.static +12 -0
  112. package/templates/workflows/ci.yml +5 -0
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { project_checks } from '#scripts/gate/project-checks'
3
4
  import { ESLINT_CACHE_FILE, ESLINT_CACHE_FLAGS } from '#scripts/josh/josh-command-types'
4
5
  import { lane_cache_run } from '#scripts/lane/lane-cache-run'
5
6
  import {
@@ -29,6 +30,38 @@ function write_output(result: BufferedProcessResult): void {
29
30
  if (result.output) process.stdout.write(result.output)
30
31
  }
31
32
 
33
+ function skipped(check: string, reason: string): BufferedProcessResult {
34
+ return { output: `${project_checks.skip_notice(check, reason)}\n`, exit_code: 0, elapsed_ms: 0 }
35
+ }
36
+
37
+ async function run_static_prettier(directory: string): Promise<BufferedProcessResult> {
38
+ if (!project_checks.has_files(directory, project_checks.WEB_FILES)) {
39
+ return skipped('prettier', 'no HTML, CSS or JavaScript files were found')
40
+ }
41
+
42
+ if (!project_checks.has_bin(directory, 'prettier')) {
43
+ return skipped('prettier', 'prettier is not installed')
44
+ }
45
+
46
+ return await buffered_process.run_buffered_process(PRETTIER_ARGS)
47
+ }
48
+
49
+ async function run_static_eslint(directory: string): Promise<BufferedProcessResult> {
50
+ if (!project_checks.has_files(directory, project_checks.SCRIPT_FILES)) {
51
+ return skipped('eslint', 'no JavaScript or TypeScript files were found')
52
+ }
53
+
54
+ if (!project_checks.has_config(directory, project_checks.ESLINT_CONFIGS)) {
55
+ return skipped('eslint', 'no ESLint configuration was found')
56
+ }
57
+
58
+ if (!project_checks.has_bin(directory, 'eslint')) {
59
+ return skipped('eslint', 'eslint is not installed')
60
+ }
61
+
62
+ return await run_eslint(ESLINT_ARGS, ESLINT_CACHE_FILE)
63
+ }
64
+
32
65
  function lint_exit_code(prettier: BufferedProcessResult, eslint: BufferedProcessResult): number {
33
66
  return buffered_process.is_process_failed(prettier) || buffered_process.is_process_failed(eslint)
34
67
  ? FAIL_EXIT_CODE
@@ -55,7 +88,17 @@ async function run_lint_checks(
55
88
  }
56
89
 
57
90
  async function run_lint_parallel_checks(): Promise<number> {
58
- return await run_lint_checks(PRETTIER_ARGS, ESLINT_ARGS)
91
+ const directory = process.cwd()
92
+ if (!project_checks.is_static(directory)) return await run_lint_checks(PRETTIER_ARGS, ESLINT_ARGS)
93
+ const [prettier, eslint] = await Promise.all([
94
+ run_static_prettier(directory),
95
+ run_static_eslint(directory),
96
+ ])
97
+
98
+ write_output(prettier)
99
+ write_output(eslint)
100
+
101
+ return lint_exit_code(prettier, eslint)
59
102
  }
60
103
 
61
104
  // `process.exitCode` rather than `process.exit()`: both reports are buffered and written here in
@@ -255,7 +255,7 @@ async function push_and_open(branch_name: string): Promise<string> {
255
255
  try {
256
256
  await git_command.push()
257
257
 
258
- return await git_gh_command.pr_create(COMMIT_MESSAGE, pull_request_body())
258
+ return await git_gh_command.pr_create(COMMIT_MESSAGE, pull_request_body(), 'ignore-for-release')
259
259
  } catch (error) {
260
260
  throw new Error(stranded_branch_message(branch_name, message_of(error)), { cause: error })
261
261
  }
@@ -0,0 +1,68 @@
1
+ interface LockDependency {
2
+ specifier: string
3
+ version: string
4
+ }
5
+
6
+ interface LockSnapshot {
7
+ dependencies?: Record<string, string>
8
+ optionalDependencies?: Record<string, string>
9
+ }
10
+
11
+ interface LockImporter {
12
+ dependencies: Record<string, LockDependency>
13
+ optionalDependencies?: Record<string, LockDependency>
14
+ }
15
+
16
+ interface Lockfile {
17
+ importers: Record<string, LockImporter>
18
+ snapshots: Record<string, LockSnapshot>
19
+ }
20
+
21
+ function collect_snapshot(
22
+ lockfile: Lockfile,
23
+ names: Set<string>,
24
+ name: string,
25
+ version: string,
26
+ ): void {
27
+ const key = `${name}@${version}`
28
+ if (names.has(key)) return
29
+ const snapshot = lockfile.snapshots[key]
30
+ if (!snapshot) throw new Error(`Missing production snapshot: ${key}`)
31
+ names.add(key)
32
+
33
+ const children = { ...snapshot.dependencies, ...snapshot.optionalDependencies }
34
+
35
+ for (const [child, child_version] of Object.entries(children)) {
36
+ collect_snapshot(lockfile, names, child, child_version)
37
+ }
38
+ }
39
+
40
+ function root_version(importer: LockImporter, name: string, specifier: string): string {
41
+ const entry = importer.dependencies[name] ?? importer.optionalDependencies?.[name]
42
+ if (entry?.specifier !== specifier) throw new Error(`Production lockfile mismatch: ${name}`)
43
+
44
+ return entry.version
45
+ }
46
+
47
+ function production_names(
48
+ lockfile: Lockfile,
49
+ dependencies: Record<string, string>,
50
+ optional_dependencies: Record<string, string> = {},
51
+ ): Set<string> {
52
+ const seen = new Set<string>()
53
+ const importer = lockfile.importers['.']
54
+ if (!importer) throw new Error('Missing root importer in production lockfile')
55
+
56
+ const roots = { ...dependencies, ...optional_dependencies }
57
+
58
+ for (const [name, specifier] of Object.entries(roots)) {
59
+ collect_snapshot(lockfile, seen, name, root_version(importer, name, specifier))
60
+ }
61
+
62
+ return new Set([...seen].map((key) => key.slice(0, key.lastIndexOf('@'))))
63
+ }
64
+
65
+ const production_dependency_graph = { names: production_names }
66
+
67
+ export type { Lockfile }
68
+ export { production_dependency_graph }
@@ -0,0 +1,62 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { execaSync } from 'execa'
5
+
6
+ const root = process.cwd()
7
+ const scratch = mkdtempSync(path.join(os.tmpdir(), 'kit-eslint-install-'))
8
+ const PACKAGE_JSON = 'package.json'
9
+ const IGNORE_SCRIPTS = '--ignore-scripts'
10
+ const manifest = JSON.parse(readFileSync(path.join(root, PACKAGE_JSON), 'utf8')) as {
11
+ devDependencies: Record<string, string>
12
+ peerDependencies: Record<string, string>
13
+ }
14
+ const eslint_peers = Object.keys(manifest.peerDependencies).filter((name) =>
15
+ /^(?:@eslint\/|@stylistic\/|eslint|globals$|typescript)/u.test(name),
16
+ )
17
+
18
+ try {
19
+ const { stdout } = execaSync(
20
+ 'pnpm',
21
+ ['pack', IGNORE_SCRIPTS, '--pack-destination', scratch, '--json'],
22
+ { cwd: root },
23
+ )
24
+ const { filename } = JSON.parse(stdout) as { filename: string }
25
+ const probe = path.join(scratch, 'consumer.mjs')
26
+ const tarball = path.resolve(scratch, filename)
27
+
28
+ writeFileSync(
29
+ path.join(scratch, '.npmrc'),
30
+ 'auto-install-peers=false\nstrict-peer-dependencies=true\n',
31
+ )
32
+ writeFileSync(path.join(scratch, PACKAGE_JSON), '{"private":true,"type":"module"}\n')
33
+ const peer_specs = eslint_peers.map((name) => {
34
+ const version = manifest.devDependencies[name]
35
+
36
+ if (version === undefined) throw new Error(`Missing development dependency for ${name}`)
37
+
38
+ return `${name}@${version}`
39
+ })
40
+
41
+ execaSync('pnpm', ['--dir', scratch, 'add', IGNORE_SCRIPTS, tarball, ...peer_specs], {
42
+ cwd: root,
43
+ stdio: 'inherit',
44
+ })
45
+ writeFileSync(path.join(scratch, '.gitignore'), '')
46
+ writeFileSync(
47
+ probe,
48
+ `import { create_vanilla_config } from '@joshuafolkken/kit/eslint/vanilla'
49
+ import { ESLint } from 'eslint'
50
+ const config = create_vanilla_config({ gitignore_path: new URL('./.gitignore', import.meta.url), tsconfig_root_dir: process.cwd() })
51
+ const lint = new ESLint({ cwd: process.cwd(), overrideConfigFile: true, overrideConfig: config })
52
+ const [result] = await lint.lintText('var foo = 1', { filePath: 'probe.js' })
53
+ if (result.fatalErrorCount !== 0 || !result.messages.some((message) => message.ruleId === 'no-var')) process.exit(1)
54
+ `,
55
+ )
56
+ execaSync(process.execPath, [probe], { cwd: scratch, stdio: 'inherit' })
57
+ console.info(
58
+ 'Packed kit installs with explicit ESLint peers and its public config lints successfully.',
59
+ )
60
+ } finally {
61
+ rmSync(scratch, { recursive: true, force: true })
62
+ }
@@ -13,6 +13,8 @@ import type { PropagateTarget } from './propagate-targets'
13
13
  const STEP_PRECHECK = 'working tree check'
14
14
  const STEP_UPGRADE = 'josh vu'
15
15
  const STEP_SYNC = 'josh sync'
16
+ const STEP_LINT_RELATED = 'related lint check'
17
+ const STEP_TEST_RELATED = 'related test check'
16
18
  const STEP_VERIFY = 'verification gate'
17
19
  const STEP_ISSUE = 'open issue'
18
20
  const STEP_PR = 'josh git'
@@ -23,6 +25,8 @@ const STEP_ORDER: ReadonlyArray<string> = [
23
25
  STEP_PRECHECK,
24
26
  STEP_UPGRADE,
25
27
  STEP_SYNC,
28
+ STEP_LINT_RELATED,
29
+ STEP_TEST_RELATED,
26
30
  STEP_VERIFY,
27
31
  STEP_ISSUE,
28
32
  STEP_PR,
@@ -187,6 +191,8 @@ const propagate_run = {
187
191
  STEP_ISSUE,
188
192
  STEP_UPGRADE,
189
193
  STEP_SYNC,
194
+ STEP_LINT_RELATED,
195
+ STEP_TEST_RELATED,
190
196
  STEP_VERIFY,
191
197
  STEP_PR,
192
198
  STEP_ORDER,
@@ -29,6 +29,8 @@ const VERIFY_SCRIPT = `pnpm josh ${GATE_COMMAND}`
29
29
  // The commands the release-independent spawning steps run inside the target's own directory. The
30
30
  // upgrade and the sync are not here: both are per toolkit, so they are built from the plan instead.
31
31
  const STEP_COMMANDS: Readonly<Record<string, ReadonlyArray<string>>> = {
32
+ [propagate_run.STEP_LINT_RELATED]: ['pnpm', 'josh', 'lint:related'],
33
+ [propagate_run.STEP_TEST_RELATED]: ['pnpm', 'josh', 'test:related'],
32
34
  [propagate_run.STEP_VERIFY]: ['sh', '-c', VERIFY_SCRIPT],
33
35
  }
34
36
 
@@ -0,0 +1,11 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { github_release } from './github-release'
3
+
4
+ const token = process.env['GH_TOKEN']
5
+ const tag = process.env['RELEASE_TAG']
6
+
7
+ if (!token || !tag) throw new Error('GH_TOKEN and RELEASE_TAG are required')
8
+
9
+ const tags = execFileSync('git', ['tag', '--list'], { encoding: 'utf8' }).trim().split('\n')
10
+
11
+ console.info(await github_release.publish(fetch, token, tag, { tags }))
@@ -0,0 +1,220 @@
1
+ import { setTimeout } from 'node:timers/promises'
2
+ import semver from 'semver'
3
+ import { z } from 'zod'
4
+
5
+ const API_URL = 'https://api.github.com/repos/joshuafolkken/kit/releases'
6
+ const WORKFLOW_RUNS_URL =
7
+ 'https://api.github.com/repos/joshuafolkken/kit/actions/workflows/publish.yml/runs?event=repository_dispatch&per_page=100'
8
+ const WORKFLOW_JOBS_URL = 'https://api.github.com/repos/joshuafolkken/kit/actions/runs'
9
+ const TAG_PATTERN = /^v\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$/u
10
+ const REQUEST_TIMEOUT_MS = 30_000
11
+ const NOT_FOUND_STATUS = 404
12
+ // Tags through v1.887.0 predate automatic GitHub Releases and must not block the first new release.
13
+ const AUTOMATION_START_TAG = 'v1.887.0'
14
+ const POLL_INTERVAL_MS = 30_000
15
+ const MAX_WAIT_ATTEMPTS = 120
16
+ const RELEASE_SCHEMA = z.object({ tag_name: z.string().min(1) })
17
+ const NOTES_SCHEMA = z.object({ name: z.string().min(1), body: z.string().min(1) })
18
+ const RUN_SCHEMA = z.object({ id: z.number(), display_title: z.string() })
19
+ const JOB_SCHEMA = z.object({
20
+ name: z.string(),
21
+ status: z.string(),
22
+ conclusion: z.string().nullable(),
23
+ })
24
+ const RUNS_SCHEMA = z.object({ workflow_runs: z.array(RUN_SCHEMA) })
25
+ const JOBS_SCHEMA = z.object({ jobs: z.array(JOB_SCHEMA) })
26
+ const PREREQUISITES = ['publish-github', 'publish-npm', 'update-production']
27
+
28
+ type ReleaseRequest = (url: string, init: RequestInit) => Promise<Response>
29
+ type Wait = () => Promise<void>
30
+
31
+ interface PublishOptions {
32
+ tags?: ReadonlyArray<string>
33
+ wait?: Wait
34
+ }
35
+
36
+ function request_options(token: string, method: string, body?: object): RequestInit {
37
+ return {
38
+ method,
39
+ headers: {
40
+ Accept: 'application/vnd.github+json',
41
+ Authorization: `Bearer ${token}`,
42
+ 'X-GitHub-Api-Version': '2022-11-28',
43
+ },
44
+ ...(body !== undefined && { body: JSON.stringify(body) }),
45
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
46
+ }
47
+ }
48
+
49
+ async function read_response(response: Response): Promise<unknown> {
50
+ if (!response.ok) throw new Error(`GitHub Releases API returned HTTP ${String(response.status)}`)
51
+
52
+ return await response.json()
53
+ }
54
+
55
+ async function existing_release(
56
+ request: ReleaseRequest,
57
+ token: string,
58
+ tag: string,
59
+ ): Promise<boolean> {
60
+ const response = await request(
61
+ `${API_URL}/tags/${encodeURIComponent(tag)}`,
62
+ request_options(token, 'GET'),
63
+ )
64
+ if (response.status === NOT_FOUND_STATUS) return false
65
+
66
+ RELEASE_SCHEMA.parse(await read_response(response))
67
+
68
+ return true
69
+ }
70
+
71
+ async function previous_tag(request: ReleaseRequest, token: string): Promise<string> {
72
+ const response = await request(`${API_URL}/latest`, request_options(token, 'GET'))
73
+ const release = RELEASE_SCHEMA.parse(await read_response(response))
74
+
75
+ return release.tag_name
76
+ }
77
+
78
+ function predecessor(tags: ReadonlyArray<string>, target: string): string | undefined {
79
+ return tags
80
+ .filter(
81
+ (tag) => semver.valid(tag) && semver.gt(tag, AUTOMATION_START_TAG) && semver.lt(tag, target),
82
+ )
83
+ .toSorted((left, right) => semver.compare(right, left))[0]
84
+ }
85
+
86
+ function is_failed_job(job: z.infer<typeof JOB_SCHEMA>): boolean {
87
+ if (job.status !== 'completed' || job.conclusion === 'success') return false
88
+
89
+ return PREREQUISITES.some((name) => job.name === name || job.name.startsWith(`${name} /`))
90
+ }
91
+
92
+ function publication_failed(jobs: Array<z.infer<typeof JOB_SCHEMA>>): boolean {
93
+ return jobs.some((job) => is_failed_job(job))
94
+ }
95
+
96
+ async function failed_publication(
97
+ request: ReleaseRequest,
98
+ token: string,
99
+ tag: string,
100
+ ): Promise<boolean> {
101
+ const runs_response = await request(WORKFLOW_RUNS_URL, request_options(token, 'GET'))
102
+ const runs = RUNS_SCHEMA.parse(await read_response(runs_response)).workflow_runs
103
+ const run = runs.find((item) => item.display_title === `Publish ${tag}`)
104
+ if (!run) return false
105
+
106
+ const url = `${WORKFLOW_JOBS_URL}/${String(run.id)}/jobs?per_page=100`
107
+ const jobs_response = await request(url, request_options(token, 'GET'))
108
+
109
+ return publication_failed(JOBS_SCHEMA.parse(await read_response(jobs_response)).jobs)
110
+ }
111
+
112
+ async function default_wait(): Promise<void> {
113
+ await setTimeout(POLL_INTERVAL_MS)
114
+ }
115
+
116
+ async function release_state(
117
+ request: ReleaseRequest,
118
+ token: string,
119
+ tag: string,
120
+ ): Promise<'ready' | 'failed' | 'pending'> {
121
+ if (await existing_release(request, token, tag)) return 'ready'
122
+ if (await failed_publication(request, token, tag)) return 'failed'
123
+
124
+ return 'pending'
125
+ }
126
+
127
+ async function wait_for_release(
128
+ request: ReleaseRequest,
129
+ token: string,
130
+ tag: string,
131
+ wait: Wait,
132
+ ): Promise<boolean> {
133
+ for (let attempt = 0; attempt < MAX_WAIT_ATTEMPTS; attempt += 1) {
134
+ const state = await release_state(request, token, tag)
135
+ if (state !== 'pending') return state === 'ready'
136
+
137
+ await wait()
138
+ }
139
+
140
+ throw new Error(`Timed out waiting for previous GitHub Release ${tag}`)
141
+ }
142
+
143
+ async function baseline_tag(
144
+ request: ReleaseRequest,
145
+ token: string,
146
+ tag: string,
147
+ options: PublishOptions,
148
+ ): Promise<string> {
149
+ const prior = predecessor(options.tags ?? [], tag)
150
+ if (!prior) return await previous_tag(request, token)
151
+
152
+ if (!(await wait_for_release(request, token, prior, options.wait ?? default_wait))) {
153
+ return await baseline_tag(request, token, prior, options)
154
+ }
155
+
156
+ return prior
157
+ }
158
+
159
+ async function generate_notes(
160
+ request: ReleaseRequest,
161
+ token: string,
162
+ tag: string,
163
+ previous: string,
164
+ ): Promise<z.infer<typeof NOTES_SCHEMA>> {
165
+ const response = await request(
166
+ `${API_URL}/generate-notes`,
167
+ request_options(token, 'POST', {
168
+ tag_name: tag,
169
+ previous_tag_name: previous,
170
+ configuration_file_path: '.github/release.yml',
171
+ }),
172
+ )
173
+
174
+ return NOTES_SCHEMA.parse(await read_response(response))
175
+ }
176
+
177
+ async function create_release(
178
+ request: ReleaseRequest,
179
+ token: string,
180
+ tag: string,
181
+ previous: string,
182
+ ): Promise<void> {
183
+ const notes = await generate_notes(request, token, tag, previous)
184
+ const response = await request(
185
+ API_URL,
186
+ request_options(token, 'POST', {
187
+ tag_name: tag,
188
+ name: notes.name,
189
+ body: notes.body,
190
+ draft: false,
191
+ prerelease: false,
192
+ }),
193
+ )
194
+
195
+ RELEASE_SCHEMA.parse(await read_response(response))
196
+ }
197
+
198
+ async function publish(
199
+ request: ReleaseRequest,
200
+ token: string,
201
+ tag: string,
202
+ options: PublishOptions = {},
203
+ ): Promise<string> {
204
+ if (!TAG_PATTERN.test(tag)) throw new Error(`Invalid release tag: ${tag}`)
205
+ if (await existing_release(request, token, tag)) return 'already-published'
206
+
207
+ const previous = await baseline_tag(request, token, tag, options)
208
+
209
+ if (!semver.lt(previous, tag)) {
210
+ throw new Error(`Latest release ${previous} is not older than ${tag}`)
211
+ }
212
+
213
+ await create_release(request, token, tag, previous)
214
+
215
+ return `published ${tag} from ${previous}`
216
+ }
217
+
218
+ const github_release = { publish }
219
+
220
+ export { github_release }
@@ -0,0 +1,8 @@
1
+ import { publish_tag } from './publish-tag'
2
+
3
+ const ARGUMENT_START = 2
4
+ const [version, latest] = process.argv.slice(ARGUMENT_START)
5
+
6
+ if (!version || !latest) throw new Error('Package version and current latest are required')
7
+
8
+ console.info(publish_tag(version, latest))
@@ -0,0 +1,11 @@
1
+ import semver from 'semver'
2
+
3
+ function publish_tag(version: string, latest: string): string {
4
+ if (!semver.valid(version) || !semver.valid(latest)) {
5
+ throw new Error('Both package versions must be valid semantic versions')
6
+ }
7
+
8
+ return semver.gt(version, latest) ? 'latest' : 'backfill'
9
+ }
10
+
11
+ export { publish_tag }
@@ -19,6 +19,7 @@ import { release_worktree } from './release-worktree'
19
19
  const { PACKAGE_JSON } = version_targets
20
20
  const SUCCESS_EXIT_CODE = 0
21
21
  const FAILURE_EXIT_CODE = 1
22
+ const IGNORE_FOR_RELEASE_LABEL = 'ignore-for-release'
22
23
 
23
24
  function branch_name_for(version: string): string {
24
25
  return `release/v${version}`
@@ -96,9 +97,13 @@ async function commit_release(plan: ReleasePlan): Promise<void> {
96
97
  await git_command.add_path(PACKAGE_JSON)
97
98
  await git_command.commit(commit_message(plan.next_version))
98
99
  await git_command.push()
99
- console.info(
100
- await git_gh_command.pr_create(commit_message(plan.next_version), pull_request_body(plan)),
100
+ const pr_url = await git_gh_command.pr_create(
101
+ commit_message(plan.next_version),
102
+ pull_request_body(plan),
103
+ IGNORE_FOR_RELEASE_LABEL,
101
104
  )
105
+
106
+ console.info(pr_url)
102
107
  }
103
108
 
104
109
  async function merge_and_tag(plan: ReleasePlan, branch_name: string): Promise<number> {
@@ -9,6 +9,7 @@ import { filing_cap } from './filing-cap'
9
9
  import { gh_api } from './gh-api'
10
10
  import { git_force } from './git-force'
11
11
  import { implementation_cut } from './implementation-cut'
12
+ import { issue_bug_label_rule } from './issue-bug-label'
12
13
  import { issue_fold } from './issue-fold'
13
14
  import { issue_scout } from './issue-scout'
14
15
  import { josh_git_bare } from './josh-git-bare'
@@ -536,6 +537,7 @@ const DELIVERED_RULES: ReadonlyArray<DeliveredRule> = [
536
537
  // `issue-fold` have each had their turn. The non-overlapping firing point (`pkg:scout` on a package
537
538
  // add) claims a command no row above matches.
538
539
  ...oracle_consulted.ROWS,
540
+ issue_bug_label_rule.ROW,
539
541
  ]
540
542
 
541
543
  // A turn that issued more than this many calls is a turn that batched. The guard counts turns that
@@ -0,0 +1,85 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { issue_classification } from '#scripts/issue/issue-classification'
4
+ import type { GuardRun } from '#scripts/josh/hook-decision'
5
+ import { time_density_hook } from '#scripts/time-runtime/time-density-hook'
6
+ import { time_shell } from '#scripts/time-runtime/time-shell'
7
+ import { bash_triggers } from './bash-triggers'
8
+ import { issue_filing_args } from './issue-filing-args'
9
+ import { shell_segments } from './shell-segments'
10
+ import { tail_commands } from './tail-commands'
11
+
12
+ const LINT_NAMES = new Set(['issue:lint'])
13
+ const LINT_PATH = /\b(?:issue:lint|iln)\s+(?:'([^']+)'|"([^"]+)"|(\S+))/u
14
+
15
+ function lint_argument(segment: string): string | undefined {
16
+ const [, single_quoted, double_quoted, bare] = LINT_PATH.exec(segment) ?? []
17
+
18
+ return single_quoted ?? double_quoted ?? bare
19
+ }
20
+
21
+ function latest_lint_path(tail: string): string | undefined {
22
+ const segments = tail_commands
23
+ .prior_bash_commands(tail)
24
+ .flatMap((command) => shell_segments.segments_of(command))
25
+ const lint = segments.findLast((segment) => shell_segments.is_josh_command(segment, LINT_NAMES))
26
+
27
+ return lint === undefined ? undefined : lint_argument(lint)
28
+ }
29
+
30
+ function linted_body(body_path: string): string | undefined {
31
+ try {
32
+ return readFileSync(body_path, 'utf8')
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ function matches_linted_file(command: string, lint_path: string): boolean {
39
+ const filing_path = issue_filing_args.body_file(command)
40
+
41
+ return filing_path !== undefined && path.resolve(filing_path) === path.resolve(lint_path)
42
+ }
43
+
44
+ function needs_classification_labels(command: string, tail: string): boolean {
45
+ const lint_path = latest_lint_path(tail)
46
+
47
+ if (lint_path === undefined) return false
48
+ const body = linted_body(lint_path)
49
+
50
+ if (body === undefined) return false
51
+
52
+ if (!matches_linted_file(command, lint_path) || issue_classification.problems(body).length > 0) {
53
+ return true
54
+ }
55
+
56
+ const expected = issue_classification.required_labels(body)
57
+
58
+ return issue_classification.CLASSIFICATION_LABELS.some(
59
+ (label) => expected.includes(label) !== issue_filing_args.has_label(command, label),
60
+ )
61
+ }
62
+
63
+ function decide(call: { input: unknown }, run: GuardRun): boolean {
64
+ const command = time_shell.bash_command(call.input)
65
+ const tail = time_density_hook.read_tail(run.transcript)
66
+
67
+ return needs_classification_labels(command, tail)
68
+ }
69
+
70
+ const ISSUE_CLASSIFICATION_REASON =
71
+ '⛔ issue classification mismatch: use the exact body file checked by `pnpm josh issue:lint` ' +
72
+ 'in this creation call (`-F body=@<path>` or `gh issue create --body-file <path>`) and ' +
73
+ 'apply exactly the `labels:` it reports (`bug`, `enhancement`, `breaking-change`). See ' +
74
+ '`prompts/collaboration-workflow/issue-template.md`.'
75
+
76
+ const ROW = {
77
+ id: 'issue-bug-label',
78
+ is_trigger: bash_triggers.on_bash_command(bash_triggers.is_issue_filing),
79
+ reason: ISSUE_CLASSIFICATION_REASON,
80
+ decide,
81
+ }
82
+
83
+ const issue_bug_label_rule = { ROW, latest_lint_path, needs_classification_labels }
84
+
85
+ export { issue_bug_label_rule }
@@ -0,0 +1,50 @@
1
+ const SHELL_WORD = /(?:[^\s'"]|'[^']*'|"[^"]*")+/gu
2
+ const QUOTE = /['"]/gu
3
+ const RAW_FIELDS = new Set(['-f', '--raw-field'])
4
+ const FILE_FIELDS = new Set(['-F', '--field'])
5
+ const LABEL_OPTIONS = new Set(['-l', '--label'])
6
+ const BODY_FILE_OPTIONS = new Set(['--body-file'])
7
+ const INLINE_BODY_OPTIONS = new Set(['-b', '--body'])
8
+ const BODY_FILE_PREFIX = 'body=@'
9
+
10
+ function words_of(command: string): Array<string> {
11
+ return [...command.matchAll(SHELL_WORD)].map((match) => match[0].replaceAll(QUOTE, ''))
12
+ }
13
+
14
+ function values_after(words: ReadonlyArray<string>, flags: ReadonlySet<string>): Array<string> {
15
+ return words.flatMap((word, index) => {
16
+ const value = words[index + 1]
17
+
18
+ return value !== undefined && flags.has(word) ? [value] : []
19
+ })
20
+ }
21
+
22
+ function has_inline_body(words: ReadonlyArray<string>): boolean {
23
+ const has_raw = values_after(words, RAW_FIELDS).some((value) => value.startsWith('body='))
24
+
25
+ return has_raw || values_after(words, INLINE_BODY_OPTIONS).length > 0
26
+ }
27
+
28
+ function body_file(command: string): string | undefined {
29
+ const words = words_of(command)
30
+ const fields = values_after(words, FILE_FIELDS).filter((value) => value.startsWith('body='))
31
+ const options = values_after(words, BODY_FILE_OPTIONS)
32
+
33
+ if (has_inline_body(words) || fields.length + options.length !== 1) return undefined
34
+
35
+ return fields[0]?.startsWith(BODY_FILE_PREFIX)
36
+ ? fields[0].slice(BODY_FILE_PREFIX.length)
37
+ : options[0]
38
+ }
39
+
40
+ function has_label(command: string, label: string): boolean {
41
+ const words = words_of(command)
42
+ const fields = [...values_after(words, RAW_FIELDS), ...values_after(words, FILE_FIELDS)]
43
+ const options = values_after(words, LABEL_OPTIONS).flatMap((value) => value.split(','))
44
+
45
+ return fields.includes(`labels[]=${label}`) || options.includes(label)
46
+ }
47
+
48
+ const issue_filing_args = { body_file, has_label, words_of }
49
+
50
+ export { issue_filing_args }