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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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. # Each build leg uploads two artifacts (both tar.gz — tar keeps file modes,
  11. # so the exe's executable bit survives; upload-artifact's zip transport
  12. # does not preserve them):
  13. # - dsh-jsonrpc-agent-pkg-<target> — the bare single-file exe, for
  14. # consumers that want just the binary.
  15. # - deepseek-harness-python-<target> — the whole python/ directory with
  16. # that exe already embedded (the build script syncs it into the Python
  17. # runtime package): unpack and both packages pip install as-is, the
  18. # checked-in default runtime/cordis.yml is editable in place, and the
  19. # embedded exe also runs directly.
  20. #
  21. # Two explicit triggers, deliberately no per-commit CI: the exe is a
  22. # release-style deliverable, and the build (full pnpm build + pnpm deploy +
  23. # pkg across a 3-platform matrix, ~100MB per artifact) is far too expensive
  24. # to run on every push. Either dispatch it from the Actions tab, or put the
  25. # `build-exe` label on a pull request to build that PR's merge result
  26. # (remove and re-apply the label to rerun); any other label leaves the jobs
  27. # skipped. There is no `ref` input on purpose: actions/checkout already
  28. # checks out the ref the run was triggered on — the dispatched branch/tag,
  29. # or the PR merge ref.
  30. on:
  31. workflow_dispatch:
  32. inputs:
  33. targets:
  34. description: >-
  35. Comma-separated pkg targets to build. Any subset of:
  36. node24-linux-x64, node24-linux-arm64, node24-macos-arm64.
  37. Empty builds all three.
  38. type: string
  39. required: false
  40. default: ''
  41. pull_request:
  42. types: [labeled]
  43. # Runs on the same ref supersede each other (per branch/tag for dispatch,
  44. # per PR merge ref for label runs).
  45. concurrency:
  46. group: ${{ github.workflow }}-${{ github.ref }}
  47. cancel-in-progress: true
  48. # Least privilege: the jobs only read the repo; artifact upload needs no
  49. # extra scope.
  50. permissions:
  51. contents: read
  52. jobs:
  53. # Turn the `targets` input into the build matrix. The `matrix` context is
  54. # not available in a job-level `if:` (jobs.<job_id>.if only sees
  55. # github/needs/vars/inputs), so target selection happens here instead of
  56. # skipping matrix legs; an unknown target name fails the whole run loudly
  57. # instead of being silently ignored. The label gate lives here too: `build`
  58. # needs this job, so skipping it skips the whole run.
  59. plan:
  60. name: plan targets
  61. if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe'
  62. runs-on: ubuntu-latest
  63. timeout-minutes: 5
  64. outputs:
  65. matrix: ${{ steps.plan.outputs.matrix }}
  66. steps:
  67. - name: Compute matrix from targets input
  68. id: plan
  69. env:
  70. # Empty on label runs and on dispatch with the input left blank —
  71. # both mean "all three targets".
  72. TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64' }}
  73. run: |
  74. set -euo pipefail
  75. matrix='[]'
  76. IFS=',' read -r -a targets <<< "$TARGETS"
  77. for raw in "${targets[@]}"; do
  78. t="$(echo "$raw" | xargs)" # trim surrounding whitespace
  79. [ -z "$t" ] && continue
  80. # Native builds only — each target maps to a runner of its own
  81. # platform: linux-arm64 uses GitHub's hosted arm64 label
  82. # ubuntu-24.04-arm (there is no ubuntu-latest-arm), macos-arm64
  83. # uses macos-latest (Apple Silicon since macos-14).
  84. case "$t" in
  85. node24-linux-x64) runner=ubuntu-latest ;;
  86. node24-linux-arm64) runner=ubuntu-24.04-arm ;;
  87. node24-macos-arm64) runner=macos-latest ;;
  88. *)
  89. echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64."
  90. exit 1
  91. ;;
  92. esac
  93. matrix="$(jq -c --arg target "$t" --arg runner "$runner" '. + [{target: $target, runner: $runner}]' <<< "$matrix")"
  94. done
  95. if [ "$matrix" = '[]' ]; then
  96. echo "::error::The targets input selected nothing to build."
  97. exit 1
  98. fi
  99. echo "Matrix: $matrix"
  100. echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
  101. build:
  102. needs: plan
  103. name: ${{ matrix.target }}
  104. runs-on: ${{ matrix.runner }}
  105. timeout-minutes: 45
  106. strategy:
  107. fail-fast: false
  108. matrix:
  109. include: ${{ fromJSON(needs.plan.outputs.matrix) }}
  110. steps:
  111. - uses: actions/checkout@v6
  112. - uses: actions/setup-node@v6
  113. with:
  114. node-version: 24
  115. - name: Enable corepack (pnpm)
  116. run: corepack enable
  117. - name: Resolve pnpm store path
  118. id: pnpm-store
  119. run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
  120. # Unlike ci.yml (x64-only), this matrix spans two Linux architectures
  121. # that share runner.os, so runner.arch is part of the key.
  122. - uses: actions/cache@v4
  123. with:
  124. path: ${{ steps.pnpm-store.outputs.path }}
  125. key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
  126. restore-keys: |
  127. ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-
  128. # The first run per target has pkg-fetch download yao-pkg's patched
  129. # Node binary into ~/.pkg-cache; cache it so later runs skip the
  130. # download. The target string pins Node major + platform + arch;
  131. # pnpm-lock.yaml rolls the key when @yao-pkg/pkg (and with it the
  132. # pinned patched-binary version) is bumped, with restore-keys still
  133. # seeding from the previous cache.
  134. - uses: actions/cache@v4
  135. with:
  136. path: ~/.pkg-cache
  137. key: pkg-fetch-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml') }}
  138. restore-keys: |
  139. pkg-fetch-${{ matrix.target }}-
  140. - name: Install (immutable)
  141. run: pnpm install --frozen-lockfile
  142. # The script runs the whole pipeline itself (pnpm run build → pnpm
  143. # deploy --prod → pkg) and writes its output to dist-exe/ by default.
  144. - name: Build single-exe
  145. run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
  146. # The bare exe ships inside a tar.gz (the mode-preservation note in
  147. # the header): uploading dist-exe/ bare would hand consumers a 0644
  148. # file that subprocess.Popen refuses to run.
  149. - name: Pack bare exe
  150. id: pack-exe
  151. env:
  152. TARGET: ${{ matrix.target }}
  153. run: |
  154. set -euo pipefail
  155. exe_tar="dsh-jsonrpc-agent-pkg-${TARGET}.tar.gz"
  156. tar -czf "$exe_tar" -C dist-exe .
  157. ls -lh "$exe_tar"
  158. echo "tar=$exe_tar" >> "$GITHUB_OUTPUT"
  159. - uses: actions/upload-artifact@v6
  160. with:
  161. name: dsh-jsonrpc-agent-pkg-${{ matrix.target }}
  162. path: ${{ steps.pack-exe.outputs.tar }}
  163. if-no-files-found: error
  164. # After the build step, python/ is already in its complete
  165. # distributable shape — the script synced this leg's exe into
  166. # python/sdk-runtime/src/deepseek_harness_runtime/runtime/ next to the
  167. # checked-in default cordis.yml — so packing is all that is left.
  168. # Everything goes under one top-level deepseek-harness-python/
  169. # directory so unpacking never scatters files.
  170. #
  171. # Excluded: runtime/node/ (dev-only node-mode carrier, ~140MB) plus
  172. # __pycache__ / .pytest_cache / .venv / node_modules anywhere (install
  173. # or test leftovers); uv.lock stays in.
  174. #
  175. # Portability: GNU tar (ubuntu) and bsdtar (macos) both accept
  176. # `tar -czf out.tar.gz --exclude=… -C <parent> <dir>` and both treat
  177. # an excluded directory as pruned (no descent). The top-level rename
  178. # is done by copying into a temp dir first — GNU --transform / BSD -s
  179. # are single-implementation flags.
  180. - name: Pack Python SDK bundle
  181. id: pack
  182. env:
  183. TARGET: ${{ matrix.target }}
  184. run: |
  185. set -euo pipefail
  186. platform_arch="${TARGET#*-}" # node24-macos-arm64 -> macos-arm64
  187. exe="python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg-${platform_arch}"
  188. if [ ! -x "$exe" ]; then
  189. echo "::error::$exe missing or not executable — the build step did not sync this leg's exe into the Python runtime package; refusing to pack a half-empty bundle."
  190. exit 1
  191. fi
  192. staging="$(mktemp -d)"
  193. cp -R python "$staging/deepseek-harness-python"
  194. bundle="deepseek-harness-python-${platform_arch}.tar.gz"
  195. tar -czf "$bundle" \
  196. --exclude='deepseek-harness-python/sdk-runtime/src/deepseek_harness_runtime/runtime/node' \
  197. --exclude='__pycache__' \
  198. --exclude='.pytest_cache' \
  199. --exclude='.venv' \
  200. --exclude='node_modules' \
  201. -C "$staging" deepseek-harness-python
  202. rm -rf "$staging"
  203. ls -lh "$bundle"
  204. echo "bundle=$bundle" >> "$GITHUB_OUTPUT"
  205. - uses: actions/upload-artifact@v6
  206. with:
  207. name: deepseek-harness-python-${{ matrix.target }}
  208. path: ${{ steps.pack.outputs.bundle }}
  209. if-no-files-found: error