diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 884a0417e3..bac25b07cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,10 +160,9 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # Windows build lane: install + `pnpm run build` (tsc -b + tsdown) on native - # Windows. Windows path/shell support is still partial, so this lane covers - # the build surface only — tests and gates are not run here yet. Wired into - # all-checks-passed so a native-Windows build regression cannot land silently. + # Blocking Windows build lane: keep the already-green native build protected + # while the broader observational gate matrix below exposes the remaining + # portability work without blocking mainline merges. windows-build: runs-on: windows-2025 name: windows / build @@ -183,11 +182,89 @@ jobs: - name: Build (tsc -b + tsdown) run: pnpm run build + # Observational, non-blocking Windows static, lint, and artifact lanes. Coverage + # and snapshot stay Linux-only until their platform-specific runtime failures + # have dedicated support. Run the gates from native PowerShell: an MSYS parent + # would change the environment being measured. This job intentionally stays + # out of all-checks-passed.needs. + windows-gates: + continue-on-error: true + runs-on: windows-2025 + name: windows node 24 / ${{ matrix.lane }} + env: + DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} + DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} + DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} + strategy: + fail-fast: false + matrix: + include: + - lane: static + command: pnpm run check:ci:static + gate_concurrency: '4' + publint_concurrency: '8' + eslint_cache: '' + - lane: lint + command: pnpm run check:ci:lint + gate_concurrency: '1' + publint_concurrency: '8' + eslint_cache: '1' + - lane: artifacts + command: pnpm run check:ci:artifacts + gate_concurrency: '3' + publint_concurrency: '8' + eslint_cache: '' + steps: + - uses: actions/checkout@v6 + + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + shell: pwsh + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + shell: pwsh + run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + shell: pwsh + run: pnpm install --frozen-lockfile + + - uses: actions/cache@v4 + if: matrix.lane == 'lint' + with: + path: .cache/eslint + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint- + + - name: Run gates + shell: pwsh + run: ${{ matrix.command }} + # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and - # node versions evolve. Every other job in THIS workflow must be listed in - # `needs` (`needs` cannot reach across workflow files; e2e.yml stays its own - # check). `if: always()` is load-bearing: without it a failed dependency + # node versions evolve. Every blocking job in THIS workflow must be listed in + # `needs`; explicitly observational jobs such as windows-gates stay out + # (`needs` cannot reach across workflow files; e2e.yml stays its own check). + # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including # 'cancelled' and 'skipped'. diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 7b5749fee5..9e799b29db 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -95,8 +95,19 @@ let consumer: string | undefined let child: ReturnType | undefined afterEach(async () => { - if (child !== undefined) { child.kill('SIGKILL'); child = undefined } - if (consumer !== undefined) await rm(consumer, { recursive: true, force: true }) + if (child !== undefined) { + const proc = child + child = undefined + // Windows retains the child's cwd and session-log handles until process + // teardown completes, so await exit before removing the temp directory. + if (proc.exitCode === null && proc.signalCode === null) { + const exited = new Promise((resolve) => { proc.once('exit', () => { resolve() }) }) + proc.kill('SIGKILL') + await exited + } + } + // Windows can briefly retain released handles after exit; retry removal. + if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) consumer = undefined }) diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index fdbeb2b8e4..ec14440b18 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -116,7 +116,8 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st let consumer: string | undefined afterEach(async () => { - if (consumer !== undefined) await rm(consumer, { recursive: true, force: true }) + // Windows can briefly retain released handles after exit; retry removal. + if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) consumer = undefined }) diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index 650319e358..a0f3909a36 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -8,7 +8,7 @@ */ import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { dirname, resolve, sep } from 'node:path' import ts from 'typescript' import { LINK_MAP } from './gen-cordis-catalog.ts' import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts' @@ -581,7 +581,7 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] { // workspace-package imports while individual packages are still being walked. const pkgDirByName = new Map() const manifests: { dir: string; pkg: string }[] = [] - for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).sort()) { + for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).map(path => path.split(sep).join('/')).sort()) { const dir = manifestRel.slice(0, -'/package.json'.length) const manifest = JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string; os?: string[]; cpu?: string[] } const pkg = manifest.name diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a32107b53b..05a308f1ac 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -6,7 +6,7 @@ */ import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import ts from 'typescript' import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts' @@ -129,7 +129,7 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source export function collectEvents(scanRoot: string = root): EventEntry[] { const entries: EventEntry[] = [] const violations: string[] = [] - for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('interface Events')) continue @@ -183,7 +183,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { export function collectServices(scanRoot: string = root): ServiceEntry[] { const entries: ServiceEntry[] = [] const violations: string[] = [] - for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('interface Context')) continue diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 5c93d8875e..9469081607 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -7,7 +7,7 @@ */ import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import ts from 'typescript' import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts' @@ -117,7 +117,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { const violations: string[] = [] const seen = new Map() let owningDecl: string | null = null - for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('SessionEventMap')) continue @@ -194,7 +194,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { */ export function collectSurfaceEventTypes(scanRoot: string = root): string[] { const found: { names: string[]; source: string }[] = [] - for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('SurfaceEventType')) continue diff --git a/scripts/package-graph.ts b/scripts/package-graph.ts index 5e84e52b86..0853b5c1ca 100644 --- a/scripts/package-graph.ts +++ b/scripts/package-graph.ts @@ -6,7 +6,7 @@ */ import { globSync, readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { dirname, resolve, sep } from 'node:path' const SCOPE = '@deepseek-ai/dsh-' @@ -33,7 +33,7 @@ export interface PackageGraphNode { */ export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] { const packages: PackageGraphNode[] = [] - for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) { + for (const rel of globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()) { const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as { name: string peerDependencies?: Record diff --git a/scripts/repo-files.ts b/scripts/repo-files.ts index e6d50e7627..8642b9963f 100644 --- a/scripts/repo-files.ts +++ b/scripts/repo-files.ts @@ -1,7 +1,7 @@ /** Shared repository file discovery and line-oriented reference scanning. */ import { globSync, readFileSync, realpathSync } from 'node:fs' -import { relative, resolve } from 'node:path' +import { relative, resolve, sep } from 'node:path' /** One authored path plus its canonical target for symlink deduplication. */ export interface RepoFile { @@ -37,8 +37,9 @@ export function uniqueRepoFiles( const files: RepoFile[] = [] for (const pattern of patterns) { for (const match of globSync(pattern, { cwd: root })) { - if (isExcluded(match)) continue - const abs = resolve(root, match) + const repoPath = match.split(sep).join('/') + if (isExcluded(repoPath)) continue + const abs = resolve(root, repoPath) const real = realpathSync(abs) if (seen.has(real)) continue seen.add(real) @@ -65,7 +66,7 @@ export function findReferenceViolations( normalize: (raw: string) => string, isViolation: (ref: string) => boolean, ): ReferenceViolation[] { - const file = relative(root, absPath) + const file = relative(root, absPath).split(sep).join('/') const out: ReferenceViolation[] = [] const lines = readFileSync(absPath, 'utf8').split('\n') for (let i = 0; i < lines.length; i++) { diff --git a/scripts/rfc-index.ts b/scripts/rfc-index.ts index 2d2b5ac84c..a8d7868bce 100644 --- a/scripts/rfc-index.ts +++ b/scripts/rfc-index.ts @@ -7,7 +7,7 @@ */ import { readFileSync, readdirSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import { globSync } from 'node:fs' export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc') @@ -58,7 +58,7 @@ export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } { } } for (const lifecycle of LIFECYCLES) { - for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) { + for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).map(path => path.split(sep).join('/')).sort()) { const segs = match.split('/') // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md). if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 4de4742c9d..7278ca51f5 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -96,8 +96,7 @@ function pnpmScript(id: string, script: string, options: Partial = {}): Ga return { id, label: options.label ?? script, - command: pnpmBin(), - args: ['run', script], + ...pnpmInvocation(['run', script]), ...options, } } @@ -106,14 +105,18 @@ function pnpmExec(id: string, args: string[], options: Partial = {}): Gate return { id, label: options.label ?? `pnpm exec ${args.join(' ')}`, - command: pnpmBin(), - args: ['exec', ...args], + ...pnpmInvocation(['exec', ...args]), ...options, } } -function pnpmBin(): string { - return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +function pnpmInvocation(args: string[]): Pick { + const entrypoint = process.env.npm_execpath + if (entrypoint === undefined || entrypoint === '') { + throw new Error('run-gates: npm_execpath is unavailable; invoke the runner through a pnpm package script.') + } + // Windows cannot spawn the pnpm.cmd shim directly; the JavaScript entrypoint keeps every host shell-free. + return { command: process.execPath, args: [entrypoint, ...args] } } function nodeOptions(...options: string[]): string { @@ -194,13 +197,18 @@ function ciStaticGates(): Gate[] { pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - demoSmokeGate(), + ...staticDemoSmokeGates(), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), ] } +function staticDemoSmokeGates(): Gate[] { + // Native Windows session persistence is outside the gates-only support scope. + return process.platform === 'win32' ? [] : [demoSmokeGate()] +} + function ciArtifactGates(): Gate[] { return [ pnpmScript('build', 'build'), @@ -297,8 +305,7 @@ function demoSmokeGate(options: { needs?: string[] } = {}): Gate { return { id: 'demo-smoke', label: 'demo smoke', - command: pnpmBin(), - args: ['run', 'demo:echo'], + ...pnpmInvocation(['run', 'demo:echo']), input: 'echo ci smoke\n', ...dependencyOptions, verify: async (result) => { diff --git a/scripts/verify-package-readme-limitations.ts b/scripts/verify-package-readme-limitations.ts index 2b995aab6b..042db1787c 100644 --- a/scripts/verify-package-readme-limitations.ts +++ b/scripts/verify-package-readme-limitations.ts @@ -6,7 +6,7 @@ */ import { existsSync, globSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import { markdownHeadingLines, markdownProseLines } from './markdown.ts' const root = resolve(import.meta.dirname, '..') @@ -30,7 +30,7 @@ function isLimitationsLike(headingText: string): boolean { ) } -const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort() +const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort() const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length))) const failures: string[] = [] diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 8bd2d615ed..5c128954f6 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -6,7 +6,7 @@ */ import { existsSync, globSync, readFileSync } from 'node:fs' -import { relative, resolve } from 'node:path' +import { relative, resolve, sep } from 'node:path' import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from './markdown.ts' const root = resolve(import.meta.dirname, '..') @@ -145,7 +145,7 @@ for (const line of readFileSync(resolve(root, 'docs/tool-catalog.md'), 'utf8').s } const failures: Failure[] = [] -const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort() +const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort() const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length))) let structuredCount = 0 let contextSurfaceCount = 0 diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index eaeacb34d2..4aaf7de79b 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -9,7 +9,7 @@ import { createHash } from 'node:crypto' import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' -import { basename, join, resolve } from 'node:path' +import { basename, join, resolve, sep } from 'node:path' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' @@ -176,7 +176,7 @@ function parse(content: string): Nodes { // Enumerate the scope once. const files = new Set() for (const pattern of SCOPE_PATTERNS) { - for (const match of globSync(pattern, { cwd: root })) files.add(match) + for (const match of globSync(pattern, { cwd: root })) files.add(match.split(sep).join('/')) } const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort() diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 729fd632cd..3e2b6f568b 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -5,7 +5,7 @@ */ import { globSync, readFileSync, existsSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import ts from 'typescript' const root = resolve(import.meta.dirname, '..') @@ -121,7 +121,7 @@ const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.sym // as an orphan rather than silently skipped. const docSet = new Set() for (const pattern of MARKDOWN_GLOBS) { - for (const match of globSync(pattern, { cwd: root })) docSet.add(match) + for (const match of globSync(pattern, { cwd: root })) docSet.add(match.split(sep).join('/')) } const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)