@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.
- package/.claude/skills/workflow-commands/fullrun-steps.md +4 -2
- package/.claude/skills/workflow-commands/halfrun.md +3 -1
- package/.claude/skills/workflow-commands/kickoff.md +5 -3
- package/.claude/skills/workflow-commands/observation-filing.md +3 -1
- package/.claude/skills/workflow-commands/prerequisite.md +3 -1
- package/.claude/skills/workflow-commands/target-repository.md +1 -1
- package/.github/pull_request_template.md +12 -0
- package/.github/release.yml +3 -0
- package/.github/workflows/ci.yml +8 -0
- package/.github/workflows/production.yml +10 -1
- package/.github/workflows/publish.yml +72 -2
- package/CLAUDE.md +2 -0
- package/dist/CLAUDE.md +2 -0
- package/dist/CLAUDE.static.md +8 -0
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-36UE53LS.js → chunk-3HZ5MJIW.js} +2 -2
- package/dist/hooks/chunk-3V37IID6.js +82 -0
- package/dist/hooks/{chunk-FRJKVSK3.js → chunk-77RLKZ5V.js} +2 -2
- package/dist/hooks/{chunk-VGYSA2NT.js → chunk-LC765SUA.js} +16 -2
- package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NWD47MW3.js} +3 -3
- package/dist/hooks/{chunk-4OXFU3MK.js → chunk-O2FBZPSW.js} +619 -156
- package/dist/hooks/{chunk-6MQCXEEA.js → chunk-OLTAGGHH.js} +25 -88
- package/dist/hooks/{chunk-FNFOLFFV.js → chunk-OYU6KPRA.js} +1 -1
- package/dist/hooks/{chunk-PRDCNGA6.js → chunk-UANRLZV7.js} +4 -4
- package/dist/hooks/{chunk-264QY6KS.js → chunk-UULARQ5Q.js} +23 -1
- package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-XMR3BS35.js} +3 -3
- package/dist/hooks/codex-hook-adapter.js +11 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +3 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +10 -9
- package/dist/hooks/stop-guard.js +3 -3
- package/dist/josh.js +155 -12
- package/package.json +70 -19
- package/prettier/index.js +3 -8
- package/prettier/static.d.ts +3 -0
- package/prettier/static.js +10 -0
- package/prompts/collaboration-workflow/issue-template.md +11 -4
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/prompts/collaboration-workflow/wip-cap.md +2 -2
- package/prompts/review.md +2 -2
- package/prompts/testing-guide.md +1 -0
- package/scripts/build/build-claude-md.ts +6 -1
- package/scripts/ci/pr-classification.ts +114 -0
- package/scripts/document/document-byte-budget.ts +3 -2
- package/scripts/gate/gate-report.ts +2 -0
- package/scripts/gate/gate-steps.ts +50 -0
- package/scripts/gate/project-checks.ts +121 -0
- package/scripts/gate/verification-gate.ts +14 -43
- package/scripts/git/git-common-directory.ts +9 -1
- package/scripts/git/git-gh-pr-rollup.ts +29 -1
- package/scripts/git/git-gh-pr.ts +61 -1
- package/scripts/git/git-pr.ts +62 -13
- package/scripts/git/issue-labels.ts +9 -0
- package/scripts/init/distributed-paths.ts +9 -5
- package/scripts/init/hook-command-bootstrap.ts +40 -8
- package/scripts/init/hook-command-rewrite.ts +7 -3
- package/scripts/init/init-actions.ts +94 -7
- package/scripts/init/init-ai-copy.ts +104 -19
- package/scripts/init/init-static.ts +52 -0
- package/scripts/init/init.ts +97 -30
- package/scripts/init/project-profile-cli.ts +14 -0
- package/scripts/init/project-profile.ts +149 -0
- package/scripts/init/start.ts +30 -0
- package/scripts/issue/issue-bug-label.ts +13 -0
- package/scripts/issue/issue-classification.ts +74 -0
- package/scripts/issue/issue-lint-cli.ts +10 -3
- package/scripts/issue/markdown-section.ts +38 -4
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +27 -1
- package/scripts/josh/josh-command-map.ts +3 -0
- package/scripts/josh/josh-commands-development.ts +6 -0
- package/scripts/josh/josh-commands-project.ts +14 -1
- package/scripts/josh/josh-logic.ts +13 -0
- package/scripts/josh/run-github-prerequisite.ts +32 -0
- package/scripts/lane/lane-await.ts +8 -1
- package/scripts/lane/openai-lane-ship.ts +30 -0
- package/scripts/lane/openai-lane-supervisor.ts +35 -8
- package/scripts/lane/openai-review-broker.ts +260 -0
- package/scripts/lint/cspell-cached.ts +21 -0
- package/scripts/lint/lint-parallel.ts +44 -1
- package/scripts/observations/observations-flush.ts +1 -1
- package/scripts/package/production-dependency-graph.ts +68 -0
- package/scripts/package/verify-optional-eslint-install.ts +62 -0
- package/scripts/propagate/propagate-run.ts +6 -0
- package/scripts/propagate/propagate-steps.ts +2 -0
- package/scripts/release/github-release-cli.ts +11 -0
- package/scripts/release/github-release.ts +220 -0
- package/scripts/release/publish-tag-cli.ts +8 -0
- package/scripts/release/publish-tag.ts +11 -0
- package/scripts/release/release-publish.ts +7 -2
- package/scripts/rules/delivered-rules.ts +2 -0
- package/scripts/rules/issue-bug-label.ts +85 -0
- package/scripts/rules/issue-filing-args.ts +50 -0
- package/scripts/run/run-merge-cli.ts +19 -0
- package/scripts/run/run-merge.ts +9 -1
- package/scripts/run/run-ship-cli.ts +20 -0
- package/scripts/run/run-ship-detach.ts +180 -14
- package/scripts/run/run-ship-review-steps.ts +45 -25
- package/scripts/run/run-wake-session.ts +3 -1
- package/scripts/test/pilot-files.ts +0 -2
- package/scripts/test/skip-marker.ts +3 -0
- package/scripts/test/test-declared-changed.ts +2 -1
- package/scripts/test/test-declared-logic.ts +15 -10
- package/scripts/test/test-declared.ts +20 -8
- package/scripts/test/test-red-logic.ts +3 -3
- package/scripts/test/test-unit-guard.ts +24 -12
- package/scripts/version/version-check-arguments.ts +14 -0
- package/scripts/version/version-check.ts +10 -3
- package/templates/CLAUDE.static.md +8 -0
- package/templates/cursorrules.static +1 -0
- package/templates/prettierignore.static +12 -0
- 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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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 }
|