ci-master-platforms.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. /** Scheduling policy for post-merge native runtime carriers and Wine. */
  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. import { gatesForMode } from '../run-gates.ts'
  8. const root = resolve(import.meta.dirname, '../..')
  9. const masterPush = "github.event_name == 'push' && github.ref == 'refs/heads/master'"
  10. const runtimeBuilder = './.github/workflows/build-exe-for-python-sdk.yml'
  11. interface Job {
  12. if?: string | boolean
  13. uses?: string
  14. needs?: string[]
  15. with?: Record<string, unknown>
  16. secrets?: Record<string, string>
  17. steps?: Array<{ name?: string; run?: string; if?: string; uses?: string; with?: Record<string, unknown> }>
  18. 'runs-on'?: string | string[]
  19. 'continue-on-error'?: boolean
  20. }
  21. interface Workflow {
  22. on: Record<string, unknown>
  23. jobs: Record<string, Job>
  24. concurrency?: Record<string, unknown>
  25. }
  26. function workflow(name: string): Workflow {
  27. return load(readFileSync(resolve(root, '.github/workflows', name), 'utf8')) as Workflow
  28. }
  29. function commands(job: Job): string[] {
  30. return (job.steps ?? []).flatMap(step => step.run ? [step.run] : [])
  31. }
  32. // These boolean/string cases share Actions and JavaScript semantics. GitHub
  33. // supplies status functions; this probe is not a general Actions interpreter.
  34. function evaluateCondition(expression: string, cancelled: boolean, results: string[], event = 'pull_request'): boolean {
  35. const source = expression.trim().replace(/^[$][{][{]|[}][}]$/g, '')
  36. .replaceAll('needs.*.result', 'results')
  37. return runInNewContext(source, {
  38. cancelled: () => cancelled,
  39. always: () => true,
  40. contains: (values: string[], value: string) => values.includes(value),
  41. results,
  42. github: { event_name: event },
  43. }, { timeout: 1000 }) as boolean
  44. }
  45. describe('master-only platform scheduling', () => {
  46. it.each(['success', 'failure', 'skipped', 'cancelled'])(
  47. 'reports %s dependencies in active runs but never starts a cancelled-run verdict', (result) => {
  48. const aggregate = workflow('ci.yml').jobs['all-checks-passed']!
  49. const results = aggregate.needs!.map(() => 'success')
  50. results[0] = result
  51. const condition = aggregate.if as string
  52. // A status function prevents Actions from implicitly gating on success().
  53. expect(condition).toContain('!cancelled()')
  54. expect(evaluateCondition(condition, false, results)).toBe(true)
  55. expect(evaluateCondition(condition, true, results)).toBe(false)
  56. expect(evaluateCondition(condition, false, results, 'push')).toBe(false)
  57. const failureStep = aggregate.steps!.find(step => step.name === 'Fail if any needed job did not succeed')!
  58. expect(evaluateCondition(failureStep.if!, false, results)).toBe(result !== 'success')
  59. expect(failureStep.run).toContain('exit 1')
  60. },
  61. )
  62. it('distinguishes the obsolete always verdict from the cancellable status guard', () => {
  63. expect(evaluateCondition("always() && github.event_name == 'pull_request'", true, ['success'])).toBe(true)
  64. expect(evaluateCondition(workflow('ci.yml').jobs['all-checks-passed']!.if as string, true, ['success'])).toBe(false)
  65. })
  66. it('keeps only Linux and Windows x64 runtimes in required PR CI', () => {
  67. const pr = workflow('ci.yml')
  68. expect(Object.keys(pr.on)).toEqual(['pull_request'])
  69. expect(pr.jobs['python-runtime']).toMatchObject({
  70. if: "github.event_name == 'pull_request'",
  71. uses: runtimeBuilder,
  72. with: { ci: true, targets: 'node24-linux-x64,node24-win-x64' },
  73. })
  74. expect(pr.jobs.windows).toBeUndefined()
  75. expect(JSON.stringify(pr.jobs)).not.toMatch(/wine-windows-gates|check:windows-wine/)
  76. const aggregate = pr.jobs['all-checks-passed']!
  77. expect(aggregate.needs).toContain('python-runtime')
  78. expect(aggregate.needs).not.toContain('windows')
  79. expect(aggregate.needs!.every(id => id in pr.jobs)).toBe(true)
  80. expect(aggregate.if).toBe("${{ !cancelled() && github.event_name == 'pull_request' }}")
  81. expect(aggregate.steps).toContainEqual(expect.objectContaining({
  82. if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')",
  83. }))
  84. })
  85. it('runs all three deferred carriers on master pushes with fail-loud API credentials', () => {
  86. const master = workflow('ci-master.yml')
  87. expect(master.on.push).toEqual({ branches: ['master'] })
  88. expect(Object.keys(master.on).sort()).toEqual(['push', 'workflow_dispatch'])
  89. const runtime = master.jobs['python-runtime']!
  90. expect(runtime).toMatchObject({
  91. if: masterPush,
  92. uses: runtimeBuilder,
  93. with: { ci: true, targets: 'node24-linux-arm64,node24-macos-arm64,node24-macos-x64' },
  94. secrets: { DEEPSEEK_API_KEY_EXTERNAL: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}' },
  95. })
  96. expect(runtime.needs).toBeUndefined()
  97. expect(runtime['continue-on-error']).toBeUndefined()
  98. const builder = workflow('build-exe-for-python-sdk.yml')
  99. expect(builder.concurrency?.['cancel-in-progress']).toBe(
  100. '${{ !inputs.release }}',
  101. )
  102. const build = builder.jobs.build!
  103. const preflight = build.steps!.find(step => step.name === 'Preflight installed-wheel real API test (POSIX)')!
  104. expect(preflight.if).toContain('inputs.ci')
  105. expect(preflight.if).toContain("github.event_name != 'pull_request'")
  106. expect(preflight.if).toContain('github.event.pull_request.head.repo.fork')
  107. expect(preflight.if).toContain("github.event.pull_request.user.login == 'dependabot[bot]'")
  108. expect(preflight.run).toContain('exit 1')
  109. })
  110. it('runs Wine once on hosted master CI and seeds its own apt cache', () => {
  111. const master = workflow('ci-master.yml')
  112. const wine = master.jobs.windows!
  113. expect(wine).toMatchObject({ if: masterPush, 'runs-on': 'ubuntu-latest' })
  114. expect(wine.needs).toBeUndefined()
  115. expect(wine['continue-on-error']).toBeUndefined()
  116. expect(master.jobs['wine-apt-cache']).toBeUndefined()
  117. expect(Object.values(master.jobs).flatMap(commands).filter(command => command.includes('wine-windows-gates.sh')))
  118. .toEqual(['bash scripts/wine-windows-gates.sh'])
  119. expect(wine.steps).toContainEqual(expect.objectContaining({
  120. uses: 'actions/cache@v4', with: { path: '~/wine-debs', key: '${{ steps.wine-cache-key.outputs.key }}' },
  121. }))
  122. expect(commands(wine).join('\n')).toContain('--download-only wine')
  123. expect(wine.steps).toContainEqual(expect.objectContaining({ name: 'Shut down wineserver', if: 'always()' }))
  124. // Graph construction needs a pnpm entrypoint but never launches it.
  125. const previous = process.env.npm_execpath
  126. process.env.npm_execpath = '/test/pnpm.cjs'
  127. try {
  128. for (const mode of ['ci-linux-primary', 'ci-windows-complete'] as const) {
  129. expect(gatesForMode(mode).map(gate => gate.displayCommand).join('\n')).not.toMatch(/wine/i)
  130. }
  131. } finally {
  132. if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath')
  133. else process.env.npm_execpath = previous
  134. }
  135. expect(process.env.npm_execpath).toBe(previous)
  136. })
  137. it('retains the complete release matrix independently of CI scheduling', () => {
  138. const release = workflow('python-release.yml')
  139. const calls = Object.values(release.jobs).filter(job => job.uses === runtimeBuilder)
  140. expect(calls).toHaveLength(1)
  141. expect(calls[0]!.with).toMatchObject({
  142. release: true,
  143. targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64',
  144. })
  145. })
  146. })