build-exe-for-python-sdk.yml 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. name: Build single-exe
  2. # Single-file executable (single-exe) builds of the DeepSeek Harness SDK
  3. # runtime. The build pipeline and target platforms are specified in
  4. # docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md:
  5. # each target is built natively on a runner of its own platform (no
  6. # cross-compilation) by scripts/build-exe-for-python-sdk.ts, which deploys
  7. # the dsh-jsonrpc-agent-pkg closure manifest with @yao-pkg/pkg into
  8. # dist-exe/.
  9. #
  10. # The run retains exactly the four wheels that make up one Python release:
  11. # one platform-independent SDK wheel plus one native runtime wheel for each
  12. # supported platform. The matrix still exercises the bare executable and
  13. # source tree, but they are intermediate test inputs rather than artifacts.
  14. #
  15. # Two explicit triggers, deliberately no per-commit CI: the exe is a
  16. # release-style deliverable, and the build (full pnpm build + pnpm deploy +
  17. # pkg across a 3-platform matrix, ~100MB per artifact) is far too expensive
  18. # to run on every push. Either dispatch it from the Actions tab, or put the
  19. # `build-exe` label on a pull request to build that PR's merge result
  20. # (remove and re-apply the label to rerun); any other label leaves the jobs
  21. # skipped. There is no `ref` input on purpose: actions/checkout already
  22. # checks out the ref the run was triggered on — the dispatched branch/tag,
  23. # or the PR merge ref.
  24. on:
  25. workflow_dispatch:
  26. inputs:
  27. targets:
  28. description: >-
  29. Comma-separated pkg targets to build. Any subset of:
  30. node24-linux-x64, node24-linux-arm64, node24-macos-arm64.
  31. Empty builds all three.
  32. type: string
  33. required: false
  34. default: ''
  35. pull_request:
  36. types: [labeled]
  37. # Runs on the same ref supersede each other (per branch/tag for dispatch,
  38. # per PR merge ref for label runs).
  39. concurrency:
  40. group: ${{ github.workflow }}-${{ github.ref }}
  41. cancel-in-progress: true
  42. # Least privilege: the jobs only read the repo; artifact upload needs no
  43. # extra scope.
  44. permissions:
  45. contents: read
  46. jobs:
  47. # Turn the `targets` input into the build matrix. The `matrix` context is
  48. # not available in a job-level `if:` (jobs.<job_id>.if only sees
  49. # github/needs/vars/inputs), so target selection happens here instead of
  50. # skipping matrix legs; an unknown target name fails the whole run loudly
  51. # instead of being silently ignored. The label gate lives here too: `build`
  52. # needs this job, so skipping it skips the whole run.
  53. plan:
  54. name: plan targets
  55. if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe'
  56. runs-on: ubuntu-latest
  57. timeout-minutes: 5
  58. outputs:
  59. matrix: ${{ steps.plan.outputs.matrix }}
  60. version: ${{ steps.version.outputs.version }}
  61. steps:
  62. - uses: actions/checkout@v6
  63. - name: Resolve repository version
  64. id: version
  65. run: |
  66. set -euo pipefail
  67. version="$(jq -r '.version // empty' package.json)"
  68. [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
  69. echo "::error::package.json version must be stable X.Y.Z, got '$version'"
  70. exit 1
  71. }
  72. echo "version=$version" >> "$GITHUB_OUTPUT"
  73. - name: Compute matrix from targets input
  74. id: plan
  75. env:
  76. # Empty on label runs and on dispatch with the input left blank —
  77. # both mean "all three targets".
  78. TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64' }}
  79. run: |
  80. set -euo pipefail
  81. matrix='[]'
  82. IFS=',' read -r -a targets <<< "$TARGETS"
  83. for raw in "${targets[@]}"; do
  84. t="$(echo "$raw" | xargs)" # trim surrounding whitespace
  85. [ -z "$t" ] && continue
  86. # Native builds only — each target maps to a runner of its own
  87. # platform: linux-arm64 uses GitHub's hosted arm64 label
  88. # ubuntu-24.04-arm (there is no ubuntu-latest-arm), macos-arm64
  89. # uses macos-latest (Apple Silicon since macos-14).
  90. case "$t" in
  91. node24-linux-x64) runner=ubuntu-latest ;;
  92. node24-linux-arm64) runner=ubuntu-24.04-arm ;;
  93. node24-macos-arm64) runner=macos-latest ;;
  94. *)
  95. echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64."
  96. exit 1
  97. ;;
  98. esac
  99. matrix="$(jq -c --arg target "$t" --arg runner "$runner" '. + [{target: $target, runner: $runner}]' <<< "$matrix")"
  100. done
  101. if [ "$matrix" = '[]' ]; then
  102. echo "::error::The targets input selected nothing to build."
  103. exit 1
  104. fi
  105. echo "Matrix: $matrix"
  106. echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
  107. sdk-wheel:
  108. needs: plan
  109. name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
  110. runs-on: ubuntu-latest
  111. timeout-minutes: 5
  112. steps:
  113. - uses: actions/checkout@v6
  114. - uses: actions/setup-python@v6
  115. with:
  116. python-version: '3.10'
  117. - name: Install Python build tooling
  118. run: python -m pip install uv==0.11.23
  119. - name: Build release-shaped SDK wheel
  120. run: >-
  121. python scripts/build-python-release.py
  122. --package sdk
  123. --output-dir dist-python
  124. - uses: actions/upload-artifact@v6
  125. with:
  126. name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
  127. path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
  128. if-no-files-found: error
  129. build:
  130. needs: [plan, sdk-wheel]
  131. name: ${{ matrix.target }}
  132. runs-on: ${{ matrix.runner }}
  133. timeout-minutes: 45
  134. strategy:
  135. fail-fast: false
  136. matrix:
  137. include: ${{ fromJSON(needs.plan.outputs.matrix) }}
  138. steps:
  139. - uses: actions/checkout@v6
  140. - uses: actions/setup-node@v6
  141. with:
  142. node-version: 24
  143. - uses: actions/setup-python@v6
  144. with:
  145. python-version: '3.10'
  146. - name: Install Python build tooling
  147. run: python -m pip install uv==0.11.23
  148. - name: Enable corepack (pnpm)
  149. run: corepack enable
  150. - name: Resolve pnpm store path
  151. id: pnpm-store
  152. run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
  153. # Unlike ci.yml (x64-only), this matrix spans two Linux architectures
  154. # that share runner.os, so runner.arch is part of the key.
  155. - uses: actions/cache@v4
  156. with:
  157. path: ${{ steps.pnpm-store.outputs.path }}
  158. key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
  159. restore-keys: |
  160. ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-
  161. # The first run per target has pkg-fetch download yao-pkg's patched
  162. # Node binary into ~/.pkg-cache; cache it so later runs skip the
  163. # download. The target string pins Node major + platform + arch;
  164. # pnpm-lock.yaml rolls the key when @yao-pkg/pkg (and with it the
  165. # pinned patched-binary version) is bumped, with restore-keys still
  166. # seeding from the previous cache.
  167. - uses: actions/cache@v4
  168. with:
  169. path: ~/.pkg-cache
  170. key: pkg-fetch-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml') }}
  171. restore-keys: |
  172. pkg-fetch-${{ matrix.target }}-
  173. - name: Install (immutable)
  174. run: pnpm install --frozen-lockfile
  175. # The script runs the whole pipeline itself (pnpm run build → pnpm
  176. # deploy --prod → pkg) and writes its output to dist-exe/ by default.
  177. - name: Build single-exe
  178. run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
  179. - name: Resolve platform outputs
  180. id: runtime
  181. env:
  182. TARGET: ${{ matrix.target }}
  183. VERSION: ${{ needs.plan.outputs.version }}
  184. run: |
  185. set -euo pipefail
  186. platform="${TARGET#node24-}"
  187. exe="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$platform"
  188. [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; }
  189. case "$platform" in
  190. linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;;
  191. linux-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl ;;
  192. macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_11_0_arm64.whl ;;
  193. *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;;
  194. esac
  195. echo "platform=$platform" >> "$GITHUB_OUTPUT"
  196. echo "exe=$exe" >> "$GITHUB_OUTPUT"
  197. echo "wheel=$wheel" >> "$GITHUB_OUTPUT"
  198. - name: Full-turn SDK, executable snapshot, and direct-binary smoke
  199. run: >-
  200. uv run --python 3.10 --group test --project python/sdk
  201. python scripts/smoke-python-runtime.py
  202. --scenario all
  203. --exe "${{ steps.runtime.outputs.exe }}"
  204. - name: Build release-shaped runtime wheel
  205. run: >-
  206. python scripts/build-python-release.py
  207. --package runtime
  208. --platform "${{ steps.runtime.outputs.platform }}"
  209. --runtime-exe "${{ steps.runtime.outputs.exe }}"
  210. --output-dir dist-python
  211. - uses: actions/download-artifact@v8
  212. with:
  213. name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
  214. path: dist-python
  215. - name: Install only the SDK into a clean venv and run zero-config
  216. env:
  217. VERSION: ${{ needs.plan.outputs.version }}
  218. run: |
  219. set -euo pipefail
  220. python -m venv "$RUNNER_TEMP/dsh-sdk-smoke"
  221. "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \
  222. --find-links dist-python \
  223. deepseek-harness=="$VERSION"
  224. "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \
  225. --scenario sdk-default
  226. - name: Check Linux GLIBC requirements
  227. if: runner.os == 'Linux'
  228. run: |
  229. set -euo pipefail
  230. readelf --version-info "${{ steps.runtime.outputs.exe }}" | tee glibc-versions.txt
  231. maximum="$(sed -n 's/.*Name: GLIBC_\([0-9.]*\).*/\1/p' glibc-versions.txt | sort -V | tail -1)"
  232. [ -n "$maximum" ] || { echo "::error::No GLIBC requirements found"; exit 1; }
  233. dpkg --compare-versions "$maximum" le 2.28 || {
  234. echo "::error::Executable requires GLIBC_$maximum but wheel claims manylinux_2_28"
  235. exit 1
  236. }
  237. - name: Run wheel in a manylinux 2.28 container
  238. if: runner.os == 'Linux'
  239. env:
  240. RUNNER_ARCH: ${{ runner.arch }}
  241. VERSION: ${{ needs.plan.outputs.version }}
  242. run: |
  243. set -euo pipefail
  244. case "$RUNNER_ARCH" in
  245. X64) image=quay.io/pypa/manylinux_2_28_x86_64 ;;
  246. ARM64) image=quay.io/pypa/manylinux_2_28_aarch64 ;;
  247. *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;;
  248. esac
  249. docker run --rm -e VERSION -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c '
  250. /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk
  251. /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness=="$VERSION"
  252. /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default
  253. '
  254. - uses: actions/upload-artifact@v6
  255. with:
  256. name: ${{ steps.runtime.outputs.wheel }}
  257. path: dist-python/${{ steps.runtime.outputs.wheel }}
  258. if-no-files-found: error