ci-release-selfhosted.spec.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. /** Release rehearsal routing and persistent-runner isolation, without executing release builds. */
  2. import { readFileSync } from 'node:fs'
  3. import { resolve } from 'node:path'
  4. import { runInNewContext } from 'node:vm'
  5. import { load } from 'js-yaml'
  6. import { describe, expect, it } from 'vitest'
  7. const root = resolve(import.meta.dirname, '../..')
  8. const repository = 'deepseek-harness/deepseek-harness'
  9. const selfhosted = ['self-hosted', 'linux', 'x64', 'vm-backup']
  10. const hosted = 'ubuntu-24.04'
  11. interface Step {
  12. name?: string
  13. uses?: string
  14. run?: string
  15. if?: string
  16. with?: Record<string, unknown>
  17. }
  18. interface Workflow {
  19. on: Record<string, unknown>
  20. permissions: Record<string, string>
  21. concurrency?: Record<string, unknown>
  22. jobs: Record<string, { name: string; 'runs-on': string; steps: Step[] }>
  23. }
  24. function workflow(file: string): Workflow {
  25. return load(readFileSync(resolve(root, '.github/workflows', file), 'utf8')) as Workflow
  26. }
  27. // This canonical-case corpus has matching Actions/JavaScript comparison results.
  28. // This is not an Actions interpreter: string case-folding and general coercion differ.
  29. // Missing context properties use the Actions empty-string value.
  30. function evaluate(expression: string, context: Record<string, string | boolean>): unknown {
  31. const source = expression.trim().replace(/^\$\{\{|\}\}$/g, '')
  32. .replace(/\b(?:github|vars|runner)(?:\.[a-zA-Z_][a-zA-Z_0-9]*)+/g,
  33. key => JSON.stringify(context[key] ?? ''))
  34. return runInNewContext(source, { fromJSON: JSON.parse }, { timeout: 1000 }) as unknown
  35. }
  36. function assertSharedPersistentStore(run: string | undefined): void {
  37. expect(run).toContain('store_root="$HOME/.local/share/pnpm/store"')
  38. expect(run).toContain('echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"')
  39. expect(run).toContain('store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)')
  40. }
  41. const trustedPr = {
  42. 'vars.DSH_CI_FAILOVER_LINUX': 'selfhosted',
  43. 'github.repository': repository,
  44. 'github.actor': 'maintainer',
  45. 'github.event_name': 'pull_request',
  46. 'github.ref': 'refs/pull/42/merge',
  47. 'github.event.pull_request.head.repo.full_name': repository,
  48. 'github.event.pull_request.head.repo.fork': false,
  49. 'github.event.pull_request.user.login': 'contributor',
  50. }
  51. const trustedPush = {
  52. 'vars.DSH_CI_FAILOVER_LINUX': 'selfhosted',
  53. 'github.repository': repository,
  54. 'github.actor': 'maintainer',
  55. 'github.event_name': 'push',
  56. 'github.ref': 'refs/heads/master',
  57. }
  58. const fallbackCases: Array<[string, Record<string, string | boolean>]> = [
  59. ['unset switch', { ...trustedPr, 'vars.DSH_CI_FAILOVER_LINUX': '' }],
  60. ['hosted switch', { ...trustedPr, 'vars.DSH_CI_FAILOVER_LINUX': 'hosted' }],
  61. ['unknown switch', { ...trustedPr, 'vars.DSH_CI_FAILOVER_LINUX': 'true' }],
  62. ['fork PR', { ...trustedPr, 'github.event.pull_request.head.repo.full_name': 'outsider/fork', 'github.event.pull_request.head.repo.fork': true }],
  63. ['different head repository', { ...trustedPr, 'github.event.pull_request.head.repo.full_name': 'outsider/repo' }],
  64. ['fork flag', { ...trustedPr, 'github.event.pull_request.head.repo.fork': true }],
  65. ['Dependabot author rerun by maintainer', { ...trustedPr, 'github.event.pull_request.user.login': 'dependabot[bot]' }],
  66. ['Dependabot PR actor', { ...trustedPr, 'github.actor': 'dependabot[bot]' }],
  67. ['Dependabot push actor', { ...trustedPush, 'github.actor': 'dependabot[bot]' }],
  68. ['non-master push', { ...trustedPush, 'github.ref': 'refs/heads/topic' }],
  69. ['tag push', { ...trustedPush, 'github.ref': 'refs/tags/dsh-v1.0.0' }],
  70. ['push in another repository', { ...trustedPush, 'github.repository': 'outsider/fork' }],
  71. ['dispatch on master', { ...trustedPush, 'github.event_name': 'workflow_dispatch' }],
  72. ['dispatch on topic', { ...trustedPush, 'github.event_name': 'workflow_dispatch', 'github.ref': 'refs/heads/topic' }],
  73. ['dispatch on tag', { ...trustedPush, 'github.event_name': 'workflow_dispatch', 'github.ref': 'refs/tags/dsh-v1.0.0' }],
  74. ['pull_request_target', { ...trustedPr, 'github.event_name': 'pull_request_target' }],
  75. ['missing PR payload', { ...trustedPush, 'github.event_name': 'pull_request' }],
  76. ]
  77. for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['release-vendor.yml', ['pack']]] as const) {
  78. describe(file, () => {
  79. const release = workflow(file)
  80. it('preserves the logical jobs, rehearsal events and read-only permission', () => {
  81. expect(Object.keys(release.jobs)).toEqual(jobIds)
  82. expect(release.on).toEqual({ pull_request: null, push: { branches: ['master'] }, workflow_dispatch: null })
  83. expect(release.permissions).toEqual({ contents: 'read' })
  84. expect(release.concurrency).toEqual({ group: '${{ github.workflow }}-${{ github.ref }}', 'cancel-in-progress': false })
  85. })
  86. for (const jobId of jobIds) {
  87. describe(jobId, () => {
  88. const job = release.jobs[jobId]!
  89. it('routes trusted PRs and master pushes onto the existing Linux pool', () => {
  90. expect(evaluate(job['runs-on'], trustedPr)).toEqual(selfhosted)
  91. expect(evaluate(job['runs-on'], trustedPush)).toEqual(selfhosted)
  92. expect(evaluate(job['runs-on'], { ...trustedPush, 'vars.DSH_CI_FAILOVER_LINUX': '' })).toBe(hosted)
  93. })
  94. it.each(fallbackCases)('keeps %s hosted', (_name, context) => {
  95. expect(evaluate(job['runs-on'], context)).toBe(hosted)
  96. })
  97. it('cleans stale checkout output and isolates setup before any pnpm invocation', () => {
  98. expect(job.steps[0]).toMatchObject({ uses: 'actions/checkout@v6', with: { clean: true, 'persist-credentials': false } })
  99. const cacheIndex = job.steps.findIndex(step => step.run?.includes('NODE_COMPILE_CACHE='))
  100. const pnpmIndex = job.steps.findIndex(step => step.uses?.startsWith('pnpm/') || /\bpnpm\b/.test(step.run ?? ''))
  101. expect(cacheIndex).toBeGreaterThan(0)
  102. expect(cacheIndex).toBeLessThan(pnpmIndex)
  103. expect(job.steps[cacheIndex]?.run).toContain('echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"')
  104. expect(job.steps[cacheIndex]?.run).toContain('echo "npm_config_devdir=${{ runner.temp }}/node-gyp" >> "$GITHUB_ENV"')
  105. expect(job.steps[cacheIndex]?.run).toContain('echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"')
  106. expect(job.steps.find(step => step.uses === 'pnpm/action-setup@v4')?.with?.dest)
  107. .toBe('${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}')
  108. expect(job.steps.find(step => step.name === 'Install (immutable)')?.run).toBe('pnpm install --frozen-lockfile')
  109. })
  110. it('retains the configured shared npm cache', () => {
  111. expect(JSON.stringify(release)).not.toMatch(/npm_config_cache/i)
  112. })
  113. it.each(['', 'store_root="${RUNNER_TEMP%/*}/pnpm-store"', 'store_root="$RUNNER_TEMP/pnpm-store"'])(
  114. 'rejects missing, runner-private, or job-temporary store placement: %s', (replacement) => {
  115. const run = job.steps.find(step => step.name === 'Configure pnpm store path')?.run
  116. ?.replace('store_root="$HOME/.local/share/pnpm/store"', replacement)
  117. expect(() => { assertSharedPersistentStore(run) }).toThrow()
  118. },
  119. )
  120. it('uses the shared persistent store without remote cache reads or writes on self-hosted', () => {
  121. assertSharedPersistentStore(job.steps.find(step => step.name === 'Configure pnpm store path')?.run)
  122. const caches = job.steps.filter(step => step.uses?.startsWith('actions/cache'))
  123. expect(caches.map(step => step.uses)).toEqual(['actions/cache/restore@v4'])
  124. for (const step of caches) {
  125. expect(evaluate(step.if!, { 'runner.environment': 'self-hosted' })).toBe(false)
  126. expect(evaluate(step.if!, { 'runner.environment': 'github-hosted' })).toBe(true)
  127. }
  128. const nodeSetup = job.steps.find(step => step.uses === 'actions/setup-node@v6')
  129. expect(nodeSetup?.with?.cache).toBeUndefined()
  130. expect(nodeSetup?.with?.['package-manager-cache']).toBe(false)
  131. })
  132. it('retains the dependency and pack verification commands', () => {
  133. const commands = job.steps.flatMap(step => step.run === undefined ? [] : [step.run])
  134. if (jobId === 'dependencies') {
  135. expect(commands).toContain('pnpm run verify-package-dependencies')
  136. expect(commands).toContain('pnpm run verify-npm-install-layout')
  137. } else {
  138. const family = file === 'release.yml' ? 'dsh' : 'vendor'
  139. const output = family === 'dsh' ? 'dist/npm' : 'dist/npm-vendor'
  140. expect(job.steps[0]?.with?.['fetch-depth']).toBe(0)
  141. expect(commands).toContain('pnpm run release:verify --family ' + family)
  142. expect(commands).toContain('pnpm run ' + (family === 'dsh' ? 'build:official' : 'build:lib:host'))
  143. expect(commands).toContain('pnpm run release:pack --family ' + family + ' --out ' + output + ' --concurrency 8')
  144. expect(commands).toContain('pnpm run release:verify-packed-install --family ' + family + ' --from ' + output
  145. + (family === 'dsh' ? ' --from dist/npm-vendor --from dist/npm-landlock' : ''))
  146. expect(job.steps.at(-1)).toMatchObject({ uses: 'actions/upload-artifact@v4', with: { path: output + '/*', 'retention-days': 7 } })
  147. }
  148. expect(JSON.stringify(job)).not.toMatch(/secrets\.|release:publish|npm-publish/)
  149. })
  150. })
  151. }
  152. })
  153. }
  154. it.each(['release-publish.yml', 'release-vendor-publish.yml'])('keeps %s manual and entirely hosted', (file) => {
  155. const publish = workflow(file)
  156. expect(publish.on).toEqual({ workflow_dispatch: null })
  157. for (const job of Object.values(publish.jobs)) expect(job['runs-on']).toBe(hosted)
  158. })