ci-compatible-selfhosted.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join, resolve } from 'node:path'
  4. import { spawnSync } from 'node:child_process'
  5. import { runInNewContext } from 'node:vm'
  6. import * as yaml from 'js-yaml'
  7. import { describe, expect, it } from 'vitest'
  8. interface Step {
  9. name?: string
  10. uses?: string
  11. if?: string
  12. run?: string
  13. env?: Record<string, string>
  14. with?: Record<string, unknown>
  15. }
  16. interface CompatibilityJob {
  17. 'runs-on': string
  18. if: string
  19. env: Record<string, string>
  20. strategy: { 'fail-fast': boolean; matrix: { include: Array<{ node: string | number; name: string; runner: string; gate_concurrency: string }> } }
  21. steps: Step[]
  22. }
  23. const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github/workflows/ci.yml'), 'utf8')) as {
  24. jobs: { 'node-compat': CompatibilityJob; 'python-sdk': { 'runs-on': string } }
  25. }
  26. const job = workflow.jobs['node-compat']
  27. const labels = ['self-hosted', 'linux', 'x64', 'vm-backup']
  28. // This wiring check uses equal-typed, canonical-case fixtures. Actions compares
  29. // strings case-insensitively; JavaScript does not. This is not an Actions evaluator.
  30. function evaluate(expression: string, context: Record<string, unknown>): unknown {
  31. const body = expression.trim().slice(3, -2)
  32. return runInNewContext(body, {
  33. ...context, fromJSON: JSON.parse,
  34. }, { timeout: 1000 }) as unknown
  35. }
  36. function route(options: { mode?: string; author?: string; repository?: string; fork?: boolean; actor?: string } = {}): unknown {
  37. return evaluate(job['runs-on'], {
  38. vars: { DSH_CI_FAILOVER_LINUX: options.mode ?? 'selfhosted' },
  39. github: {
  40. repository: 'deepseek-harness/deepseek-harness',
  41. actor: options.actor ?? 'maintainer',
  42. event: { pull_request: {
  43. user: { login: options.author ?? 'maintainer' },
  44. head: { repo: { full_name: options.repository ?? 'deepseek-harness/deepseek-harness', fork: options.fork ?? false } },
  45. } },
  46. },
  47. matrix: { runner: 'ubuntu-latest' },
  48. })
  49. }
  50. describe('Node compatibility self-hosted routing', () => {
  51. it('uses the Linux pool only for opted-in repository-owned PRs', () => {
  52. expect(route()).toEqual(labels)
  53. for (const mode of ['', 'hosted', 'unexpected']) expect(route({ mode })).toBe('ubuntu-latest')
  54. // The blacksmith value routes the compatibility legs onto Blacksmith's
  55. // standard Linux runner regardless of PR ownership (ephemeral runners).
  56. expect(route({ mode: 'blacksmith' })).toBe('blacksmith-4vcpu-ubuntu-2404')
  57. expect(route({ author: 'dependabot[bot]', actor: 'maintainer' })).toBe('ubuntu-latest')
  58. expect(route({ repository: 'outsider/fork', fork: true })).toBe('ubuntu-latest')
  59. expect(route({ repository: 'outsider/fork', fork: false })).toBe('ubuntu-latest')
  60. expect(route({ fork: true })).toBe('ubuntu-latest')
  61. expect(route({ repository: '' })).toBe('ubuntu-latest')
  62. })
  63. it('preserves all three required version jobs and their concurrency', () => {
  64. expect(job.if).toBe("github.event_name == 'pull_request'")
  65. expect(job.strategy['fail-fast']).toBe(false)
  66. expect(job.strategy.matrix.include).toEqual([
  67. { node: '22.19', name: 'node 22.19', runner: 'ubuntu-latest', gate_concurrency: '1' },
  68. { node: '24.9', name: 'node 24.9', runner: 'ubuntu-latest', gate_concurrency: '1' },
  69. { node: 26, name: 'node 26', runner: 'ubuntu-latest', gate_concurrency: '1' },
  70. ])
  71. expect(job.env.DSH_GATE_CONCURRENCY).toBe('${{ matrix.gate_concurrency }}')
  72. expect(job.steps.map(step => step.run)).toContain('pnpm run check:node-compat')
  73. expect(job.steps.map(step => step.run)).toContain('pnpm exec vitest run packages/boot/app-boot/tests/loader-shape.compat.spec.ts')
  74. expect(workflow.jobs['python-sdk']['runs-on']).toBe('ubuntu-latest')
  75. })
  76. it('isolates version installs and enables hosted package caching only on hosted runners', () => {
  77. const setup = job.steps.find(step => step.uses === 'actions/setup-node@v6')!
  78. expect(setup.env).toEqual({
  79. NODE_OPTIONS: "${{ runner.environment == 'self-hosted' && '--import=./scripts/ci-compatible-toolcache.mjs' || '' }}",
  80. })
  81. expect(setup.with?.['node-version']).toBe('${{ matrix.node }}')
  82. expect(setup.with?.['package-manager-cache']).toBe(false)
  83. for (const [environment, cache] of [['github-hosted', 'pnpm'], ['self-hosted', '']]) {
  84. const context = { runner: { environment, temp: '/runner/temp', tool_cache: '/runner/toolcache' } }
  85. expect(evaluate(setup.with?.cache as string, context)).toBe(cache)
  86. expect(evaluate(setup.env!.NODE_OPTIONS!, context)).toBe(
  87. environment === 'self-hosted' ? '--import=./scripts/ci-compatible-toolcache.mjs' : '',
  88. )
  89. }
  90. expect(job.steps[0]?.with).toEqual({ 'persist-credentials': false })
  91. expect(job.steps.some(step => step.uses?.startsWith('actions/cache/'))).toBe(false)
  92. })
  93. it('overrides runner exports inside setup-node without affecting later Node processes', () => {
  94. const setup = job.steps.find(step => step.uses === 'actions/setup-node@v6')!
  95. const nodeOptions = evaluate(setup.env!.NODE_OPTIONS!, { runner: { environment: 'self-hosted' } }) as string
  96. const root = mkdtempSync(join(tmpdir(), 'ci-compatible preload-'))
  97. try {
  98. const env = { PATH: process.env.PATH, RUNNER_TEMP: root, RUNNER_TOOL_CACHE: join(root, 'persistent') }
  99. const probe = (options: Record<string, string | undefined>) => {
  100. const child = spawnSync(process.execPath, ['-p', 'process.env.RUNNER_TOOL_CACHE'], {
  101. cwd: resolve(import.meta.dirname, '..'), env: options, encoding: 'utf8', timeout: 10_000,
  102. })
  103. expect(child.error).toBeUndefined()
  104. expect(child.signal).toBeNull()
  105. return child
  106. }
  107. const setupChild = probe({ ...env, NODE_OPTIONS: nodeOptions })
  108. expect(setupChild.status, setupChild.stderr).toBe(0)
  109. expect(setupChild.stdout.trim()).toBe(join(root, 'node-compat-toolcache'))
  110. const normalChild = probe(env)
  111. expect(normalChild.status, normalChild.stderr).toBe(0)
  112. expect(normalChild.stdout.trim()).toBe(env.RUNNER_TOOL_CACHE)
  113. const missingTemp = probe({ ...env, RUNNER_TEMP: undefined, NODE_OPTIONS: nodeOptions })
  114. expect(missingTemp.status).not.toBe(0)
  115. expect(missingTemp.stderr).toContain('requires an absolute RUNNER_TEMP')
  116. expect(job.env).not.toHaveProperty('NODE_OPTIONS')
  117. expect(job.steps.filter(step => step.env?.NODE_OPTIONS)).toEqual([setup])
  118. } finally {
  119. rmSync(root, { recursive: true, force: true })
  120. }
  121. })
  122. it.skipIf(process.platform === 'win32')('rejects a Node executable outside its runner temporary installation', () => {
  123. const step = job.steps.find(candidate => candidate.name === 'Verify isolated Node installation')!
  124. expect(step.if).toBe("runner.environment == 'self-hosted'")
  125. for (const [executable, status] of [
  126. ['/runner temp/node-compat-toolcache/node/24.9.0/x64/bin/node', 0],
  127. ['/shared/toolcache/node/24.9.0/x64/bin/node', 1],
  128. ['/runner temp/node-compat-toolcache-other/node', 1],
  129. ] as const) {
  130. const child = spawnSync('bash', ['-e', '-u', '-o', 'pipefail', '-c', 'node() { printf "%s" "$TEST_EXECUTABLE"; }; ' + step.run!], {
  131. env: { PATH: process.env.PATH, RUNNER_TEMP: '/runner temp', TEST_EXECUTABLE: executable }, encoding: 'utf8', timeout: 10_000,
  132. })
  133. expect(child.error).toBeUndefined()
  134. expect(child.signal).toBeNull()
  135. expect(child.status, child.stderr).toBe(status)
  136. }
  137. })
  138. it.skipIf(process.platform === 'win32')('configures generated caches before pnpm without changing HOME or global links', () => {
  139. const index = job.steps.findIndex(step => step.name === 'Isolate compatibility caches')
  140. const step = job.steps[index]!
  141. expect(index).toBeGreaterThan(0)
  142. expect(index).toBeLessThan(job.steps.findIndex(candidate => candidate.uses === 'pnpm/action-setup@v4'))
  143. expect(step.if).toBe("runner.environment == 'self-hosted'")
  144. const root = mkdtempSync(join(tmpdir(), 'ci-compatible-selfhosted-'))
  145. try {
  146. const outputs = ['runner-a', 'runner-b'].map((runner) => {
  147. const envFile = join(root, runner + '.env')
  148. const temp = join(root, runner)
  149. const child = spawnSync('bash', ['-e', '-u', '-o', 'pipefail', '-c', step.run!], {
  150. env: { PATH: process.env.PATH, HOME: join(root, 'shared home'), RUNNER_TEMP: temp, GITHUB_ENV: envFile },
  151. encoding: 'utf8', timeout: 10_000,
  152. })
  153. expect(child.error).toBeUndefined()
  154. expect(child.signal).toBeNull()
  155. expect(child.status, child.stderr).toBe(0)
  156. const output = readFileSync(envFile, 'utf8')
  157. expect(output).toBe([
  158. 'NODE_COMPILE_CACHE=' + temp + '/node-compile-cache',
  159. 'npm_config_devdir=' + temp + '/node-gyp',
  160. 'PNPM_CONFIG_STORE_DIR=' + join(root, 'shared home') + '/.local/share/pnpm/store',
  161. '',
  162. ].join('\n'))
  163. return output
  164. })
  165. expect(outputs[0]).not.toBe(outputs[1])
  166. } finally {
  167. rmSync(root, { recursive: true, force: true })
  168. }
  169. })
  170. })