watcherobot 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. watcherobot-0.1.1/.github/scripts/luxiao_review.py +151 -0
  2. watcherobot-0.1.1/.github/workflows/pr-review.yml +52 -0
  3. watcherobot-0.1.1/.github/workflows/prepare-release.yml +169 -0
  4. watcherobot-0.1.1/.github/workflows/release.yml +288 -0
  5. watcherobot-0.1.1/.github/workflows/sdk-ci.yml +183 -0
  6. watcherobot-0.1.1/.gitignore +15 -0
  7. watcherobot-0.1.1/CHANGELOG.md +33 -0
  8. watcherobot-0.1.1/LICENSE +177 -0
  9. watcherobot-0.1.1/PKG-INFO +273 -0
  10. watcherobot-0.1.1/README.md +233 -0
  11. watcherobot-0.1.1/README.zh-CN.md +210 -0
  12. watcherobot-0.1.1/docs/application-marketplace/README.md +14 -0
  13. watcherobot-0.1.1/docs/application-marketplace/application-cli-reference.md +218 -0
  14. watcherobot-0.1.1/docs/application-marketplace/distribution-contract.md +124 -0
  15. watcherobot-0.1.1/docs/application-marketplace/hugging-face-oauth.md +49 -0
  16. watcherobot-0.1.1/docs/application-marketplace/implementation-progress.md +189 -0
  17. watcherobot-0.1.1/docs/application-marketplace/publishing-quality-gates.zh-CN.md +93 -0
  18. watcherobot-0.1.1/docs/application-marketplace/sdk-application-usage.md +422 -0
  19. watcherobot-0.1.1/docs/application-marketplace/sdk-application-usage.zh-CN.md +453 -0
  20. watcherobot-0.1.1/docs/bluetooth-provisioning.md +158 -0
  21. watcherobot-0.1.1/docs/cli-reference.md +378 -0
  22. watcherobot-0.1.1/docs/cli-reference.zh-CN.md +307 -0
  23. watcherobot-0.1.1/docs/contracts/daemon-control-protocol.md +48 -0
  24. watcherobot-0.1.1/docs/contracts/runtime-profile-index.md +202 -0
  25. watcherobot-0.1.1/docs/device-states/README.md +145 -0
  26. watcherobot-0.1.1/docs/face-tracking-preview.md +92 -0
  27. watcherobot-0.1.1/docs/hardware-testing.md +73 -0
  28. watcherobot-0.1.1/docs/installation.md +172 -0
  29. watcherobot-0.1.1/docs/installation.zh-CN.md +163 -0
  30. watcherobot-0.1.1/docs/microphone-audio.md +54 -0
  31. watcherobot-0.1.1/docs/migration/server-daemon-dependencies.md +76 -0
  32. watcherobot-0.1.1/docs/releasing.md +124 -0
  33. watcherobot-0.1.1/docs/resources.md +130 -0
  34. watcherobot-0.1.1/docs/troubleshooting.md +27 -0
  35. watcherobot-0.1.1/examples/README.md +27 -0
  36. watcherobot-0.1.1/examples/assets/sample_speech.wav +0 -0
  37. watcherobot-0.1.1/examples/capture_photo/app.json +8 -0
  38. watcherobot-0.1.1/examples/capture_photo/app.py +19 -0
  39. watcherobot-0.1.1/examples/face_tracking_preview/app.json +8 -0
  40. watcherobot-0.1.1/examples/face_tracking_preview/app.py +44 -0
  41. watcherobot-0.1.1/examples/hello_robot/app.json +8 -0
  42. watcherobot-0.1.1/examples/hello_robot/app.py +17 -0
  43. watcherobot-0.1.1/examples/play_audio_file/app.json +8 -0
  44. watcherobot-0.1.1/examples/play_audio_file/app.py +19 -0
  45. watcherobot-0.1.1/examples/quickstart/app.json +8 -0
  46. watcherobot-0.1.1/examples/quickstart/app.py +32 -0
  47. watcherobot-0.1.1/examples/record_microphone/app.json +8 -0
  48. watcherobot-0.1.1/examples/record_microphone/app.py +29 -0
  49. watcherobot-0.1.1/examples/sdk_media_lab/.watcherignore +1 -0
  50. watcherobot-0.1.1/examples/sdk_media_lab/README.md +101 -0
  51. watcherobot-0.1.1/examples/sdk_media_lab/app.json +11 -0
  52. watcherobot-0.1.1/examples/sdk_media_lab/app.py +78 -0
  53. watcherobot-0.1.1/examples/sdk_media_lab/assets/sample_speech.wav +0 -0
  54. watcherobot-0.1.1/examples/sdk_media_lab/icon.svg +9 -0
  55. watcherobot-0.1.1/examples/sdk_media_lab/service.py +1112 -0
  56. watcherobot-0.1.1/examples/sdk_media_lab/web/animation-confirmation.mjs +20 -0
  57. watcherobot-0.1.1/examples/sdk_media_lab/web/animation-random.mjs +50 -0
  58. watcherobot-0.1.1/examples/sdk_media_lab/web/app.js +1980 -0
  59. watcherobot-0.1.1/examples/sdk_media_lab/web/i18n.mjs +438 -0
  60. watcherobot-0.1.1/examples/sdk_media_lab/web/index.html +375 -0
  61. watcherobot-0.1.1/examples/sdk_media_lab/web/media-resource-policy.mjs +61 -0
  62. watcherobot-0.1.1/examples/sdk_media_lab/web/mjpeg-chunk-reassembly.mjs +124 -0
  63. watcherobot-0.1.1/examples/sdk_media_lab/web/resource-health.mjs +107 -0
  64. watcherobot-0.1.1/examples/sdk_media_lab/web/rtc-audio-capture.mjs +11 -0
  65. watcherobot-0.1.1/examples/sdk_media_lab/web/rtc-audio-health.mjs +43 -0
  66. watcherobot-0.1.1/examples/sdk_media_lab/web/rtc-audio-latency.mjs +86 -0
  67. watcherobot-0.1.1/examples/sdk_media_lab/web/styles.css +515 -0
  68. watcherobot-0.1.1/examples/sdk_media_lab/web/video-feedback.mjs +68 -0
  69. watcherobot-0.1.1/examples/sdk_media_lab/web/video-frame-queue.mjs +19 -0
  70. watcherobot-0.1.1/pyproject.toml +73 -0
  71. watcherobot-0.1.1/src/watcherobot/__init__.py +83 -0
  72. watcherobot-0.1.1/src/watcherobot/_internal/__init__.py +1 -0
  73. watcherobot-0.1.1/src/watcherobot/_internal/audio_status.py +40 -0
  74. watcherobot-0.1.1/src/watcherobot/application/__init__.py +30 -0
  75. watcherobot-0.1.1/src/watcherobot/application/channels.py +35 -0
  76. watcherobot-0.1.1/src/watcherobot/application/context.py +99 -0
  77. watcherobot-0.1.1/src/watcherobot/application/desktop.py +26 -0
  78. watcherobot-0.1.1/src/watcherobot/application/ignore.py +54 -0
  79. watcherobot-0.1.1/src/watcherobot/application/project.py +336 -0
  80. watcherobot-0.1.1/src/watcherobot/application/rtc.py +402 -0
  81. watcherobot-0.1.1/src/watcherobot/application/transport.py +533 -0
  82. watcherobot-0.1.1/src/watcherobot/audio.py +81 -0
  83. watcherobot-0.1.1/src/watcherobot/cli.py +1186 -0
  84. watcherobot-0.1.1/src/watcherobot/distribution/__init__.py +154 -0
  85. watcherobot-0.1.1/src/watcherobot/distribution/catalog_submission.py +176 -0
  86. watcherobot-0.1.1/src/watcherobot/distribution/check.py +66 -0
  87. watcherobot-0.1.1/src/watcherobot/distribution/cli.py +1077 -0
  88. watcherobot-0.1.1/src/watcherobot/distribution/credentials.py +85 -0
  89. watcherobot-0.1.1/src/watcherobot/distribution/download.py +242 -0
  90. watcherobot-0.1.1/src/watcherobot/distribution/events.py +172 -0
  91. watcherobot-0.1.1/src/watcherobot/distribution/hf_marketplace.py +286 -0
  92. watcherobot-0.1.1/src/watcherobot/distribution/hf_publish.py +351 -0
  93. watcherobot-0.1.1/src/watcherobot/distribution/hub_http.py +128 -0
  94. watcherobot-0.1.1/src/watcherobot/distribution/install.py +1010 -0
  95. watcherobot-0.1.1/src/watcherobot/distribution/login.py +318 -0
  96. watcherobot-0.1.1/src/watcherobot/distribution/marketplace.py +228 -0
  97. watcherobot-0.1.1/src/watcherobot/distribution/oauth_http.py +229 -0
  98. watcherobot-0.1.1/src/watcherobot/distribution/ports.py +327 -0
  99. watcherobot-0.1.1/src/watcherobot/distribution/publish.py +205 -0
  100. watcherobot-0.1.1/src/watcherobot/distribution/publish_files.py +140 -0
  101. watcherobot-0.1.1/src/watcherobot/distribution/source_files.py +107 -0
  102. watcherobot-0.1.1/src/watcherobot/distribution/submit.py +398 -0
  103. watcherobot-0.1.1/src/watcherobot/errors.py +50 -0
  104. watcherobot-0.1.1/src/watcherobot/inputs.py +180 -0
  105. watcherobot-0.1.1/src/watcherobot/job.py +115 -0
  106. watcherobot-0.1.1/src/watcherobot/media.py +243 -0
  107. watcherobot-0.1.1/src/watcherobot/opus.py +79 -0
  108. watcherobot-0.1.1/src/watcherobot/protocol.py +95 -0
  109. watcherobot-0.1.1/src/watcherobot/provisioning/__init__.py +41 -0
  110. watcherobot-0.1.1/src/watcherobot/provisioning/backend.py +50 -0
  111. watcherobot-0.1.1/src/watcherobot/provisioning/bleak_backend.py +271 -0
  112. watcherobot-0.1.1/src/watcherobot/provisioning/errors.py +82 -0
  113. watcherobot-0.1.1/src/watcherobot/provisioning/models.py +102 -0
  114. watcherobot-0.1.1/src/watcherobot/provisioning/protocol.py +190 -0
  115. watcherobot-0.1.1/src/watcherobot/provisioning/service.py +526 -0
  116. watcherobot-0.1.1/src/watcherobot/py.typed +0 -0
  117. watcherobot-0.1.1/src/watcherobot/robot.py +1093 -0
  118. watcherobot-0.1.1/src/watcherobot/runtime/__init__.py +1 -0
  119. watcherobot-0.1.1/src/watcherobot/runtime/daemon/__init__.py +16 -0
  120. watcherobot-0.1.1/src/watcherobot/runtime/daemon/__main__.py +159 -0
  121. watcherobot-0.1.1/src/watcherobot/runtime/daemon/application/__init__.py +44 -0
  122. watcherobot-0.1.1/src/watcherobot/runtime/daemon/application/bridge.py +237 -0
  123. watcherobot-0.1.1/src/watcherobot/runtime/daemon/application/client.py +140 -0
  124. watcherobot-0.1.1/src/watcherobot/runtime/daemon/application/launcher.py +396 -0
  125. watcherobot-0.1.1/src/watcherobot/runtime/daemon/application/logging.py +89 -0
  126. watcherobot-0.1.1/src/watcherobot/runtime/daemon/application/manifest.py +352 -0
  127. watcherobot-0.1.1/src/watcherobot/runtime/daemon/application/runtime.py +551 -0
  128. watcherobot-0.1.1/src/watcherobot/runtime/daemon/application/session.py +146 -0
  129. watcherobot-0.1.1/src/watcherobot/runtime/daemon/connections/__init__.py +39 -0
  130. watcherobot-0.1.1/src/watcherobot/runtime/daemon/connections/device_state.py +41 -0
  131. watcherobot-0.1.1/src/watcherobot/runtime/daemon/connections/identity.py +95 -0
  132. watcherobot-0.1.1/src/watcherobot/runtime/daemon/connections/registry.py +134 -0
  133. watcherobot-0.1.1/src/watcherobot/runtime/daemon/connections/websocket_server.py +482 -0
  134. watcherobot-0.1.1/src/watcherobot/runtime/daemon/control/__init__.py +5 -0
  135. watcherobot-0.1.1/src/watcherobot/runtime/daemon/control/rest.py +641 -0
  136. watcherobot-0.1.1/src/watcherobot/runtime/daemon/instance.py +146 -0
  137. watcherobot-0.1.1/src/watcherobot/runtime/daemon/logging.py +90 -0
  138. watcherobot-0.1.1/src/watcherobot/runtime/daemon/maintenance/__init__.py +5 -0
  139. watcherobot-0.1.1/src/watcherobot/runtime/daemon/maintenance/card_reader.py +906 -0
  140. watcherobot-0.1.1/src/watcherobot/runtime/daemon/maintenance/releases.py +426 -0
  141. watcherobot-0.1.1/src/watcherobot/runtime/daemon/maintenance/service.py +1475 -0
  142. watcherobot-0.1.1/src/watcherobot/runtime/daemon/maintenance/work_assets.py +241 -0
  143. watcherobot-0.1.1/src/watcherobot/runtime/daemon/maintenance/works.py +722 -0
  144. watcherobot-0.1.1/src/watcherobot/runtime/daemon/pairing/__init__.py +14 -0
  145. watcherobot-0.1.1/src/watcherobot/runtime/daemon/pairing/protocol.py +434 -0
  146. watcherobot-0.1.1/src/watcherobot/runtime/daemon/pairing/session.py +302 -0
  147. watcherobot-0.1.1/src/watcherobot/runtime/daemon/pairing/udp.py +659 -0
  148. watcherobot-0.1.1/src/watcherobot/runtime/daemon/preview/__init__.py +5 -0
  149. watcherobot-0.1.1/src/watcherobot/runtime/daemon/preview/face_tracking.py +113 -0
  150. watcherobot-0.1.1/src/watcherobot/runtime/daemon/preview/udp_feedback.py +67 -0
  151. watcherobot-0.1.1/src/watcherobot/runtime/daemon/preview/udp_protocol.py +276 -0
  152. watcherobot-0.1.1/src/watcherobot/runtime/daemon/preview/udp_service.py +214 -0
  153. watcherobot-0.1.1/src/watcherobot/runtime/daemon/routing/__init__.py +5 -0
  154. watcherobot-0.1.1/src/watcherobot/runtime/daemon/routing/raw.py +93 -0
  155. watcherobot-0.1.1/src/watcherobot/runtime/daemon/runtime.py +543 -0
  156. watcherobot-0.1.1/src/watcherobot/runtime/frozen_entry.py +32 -0
  157. watcherobot-0.1.1/src/watcherobot/vision.py +523 -0
  158. watcherobot-0.1.1/tests/__init__.py +1 -0
  159. watcherobot-0.1.1/tests/application/__init__.py +1 -0
  160. watcherobot-0.1.1/tests/application/test_channels.py +25 -0
  161. watcherobot-0.1.1/tests/application/test_context.py +124 -0
  162. watcherobot-0.1.1/tests/application/test_project_init.py +171 -0
  163. watcherobot-0.1.1/tests/application/test_rtc.py +452 -0
  164. watcherobot-0.1.1/tests/application/test_transport.py +405 -0
  165. watcherobot-0.1.1/tests/distribution/_publishing_fakes.py +218 -0
  166. watcherobot-0.1.1/tests/distribution/test_auth_cli.py +247 -0
  167. watcherobot-0.1.1/tests/distribution/test_catalog_submission.py +185 -0
  168. watcherobot-0.1.1/tests/distribution/test_check.py +196 -0
  169. watcherobot-0.1.1/tests/distribution/test_credentials.py +87 -0
  170. watcherobot-0.1.1/tests/distribution/test_daemon_boundary.py +26 -0
  171. watcherobot-0.1.1/tests/distribution/test_distribution_docs.py +144 -0
  172. watcherobot-0.1.1/tests/distribution/test_distribution_entrypoint.py +48 -0
  173. watcherobot-0.1.1/tests/distribution/test_download.py +337 -0
  174. watcherobot-0.1.1/tests/distribution/test_download_cli.py +244 -0
  175. watcherobot-0.1.1/tests/distribution/test_events.py +110 -0
  176. watcherobot-0.1.1/tests/distribution/test_hf_marketplace.py +377 -0
  177. watcherobot-0.1.1/tests/distribution/test_hf_publish.py +389 -0
  178. watcherobot-0.1.1/tests/distribution/test_hub_http.py +117 -0
  179. watcherobot-0.1.1/tests/distribution/test_install.py +457 -0
  180. watcherobot-0.1.1/tests/distribution/test_install_cli.py +213 -0
  181. watcherobot-0.1.1/tests/distribution/test_login.py +499 -0
  182. watcherobot-0.1.1/tests/distribution/test_marketplace.py +262 -0
  183. watcherobot-0.1.1/tests/distribution/test_marketplace_cli.py +244 -0
  184. watcherobot-0.1.1/tests/distribution/test_oauth_http.py +224 -0
  185. watcherobot-0.1.1/tests/distribution/test_ports.py +117 -0
  186. watcherobot-0.1.1/tests/distribution/test_publish.py +148 -0
  187. watcherobot-0.1.1/tests/distribution/test_publish_cli.py +241 -0
  188. watcherobot-0.1.1/tests/distribution/test_publish_files.py +111 -0
  189. watcherobot-0.1.1/tests/distribution/test_publish_ports.py +189 -0
  190. watcherobot-0.1.1/tests/distribution/test_source_files.py +89 -0
  191. watcherobot-0.1.1/tests/distribution/test_submit.py +266 -0
  192. watcherobot-0.1.1/tests/distribution/test_submit_cli.py +154 -0
  193. watcherobot-0.1.1/tests/fixtures/contracts/watcher_lan_pairing_v1.json +121 -0
  194. watcherobot-0.1.1/tests/fixtures/contracts/wspk_v1.json +30 -0
  195. watcherobot-0.1.1/tests/js/test_animation_confirmation.mjs +40 -0
  196. watcherobot-0.1.1/tests/js/test_animation_random.mjs +37 -0
  197. watcherobot-0.1.1/tests/js/test_media_resource_policy.mjs +202 -0
  198. watcherobot-0.1.1/tests/js/test_mjpeg_chunk_reassembly.mjs +89 -0
  199. watcherobot-0.1.1/tests/js/test_resource_health.mjs +218 -0
  200. watcherobot-0.1.1/tests/js/test_rtc_audio_capture.mjs +23 -0
  201. watcherobot-0.1.1/tests/js/test_rtc_audio_health.mjs +101 -0
  202. watcherobot-0.1.1/tests/js/test_rtc_audio_latency.mjs +78 -0
  203. watcherobot-0.1.1/tests/js/test_sdk_media_lab_i18n.mjs +35 -0
  204. watcherobot-0.1.1/tests/js/test_video_feedback.mjs +82 -0
  205. watcherobot-0.1.1/tests/js/test_video_frame_queue.mjs +39 -0
  206. watcherobot-0.1.1/tests/provisioning/__init__.py +1 -0
  207. watcherobot-0.1.1/tests/provisioning/test_bleak_backend.py +316 -0
  208. watcherobot-0.1.1/tests/provisioning/test_cli.py +242 -0
  209. watcherobot-0.1.1/tests/provisioning/test_hardware_tool.py +83 -0
  210. watcherobot-0.1.1/tests/provisioning/test_protocol.py +179 -0
  211. watcherobot-0.1.1/tests/provisioning/test_service.py +571 -0
  212. watcherobot-0.1.1/tests/runtime/__init__.py +1 -0
  213. watcherobot-0.1.1/tests/runtime/pairing_helpers.py +71 -0
  214. watcherobot-0.1.1/tests/runtime/test_application_bridge.py +123 -0
  215. watcherobot-0.1.1/tests/runtime/test_application_client.py +173 -0
  216. watcherobot-0.1.1/tests/runtime/test_application_launcher.py +727 -0
  217. watcherobot-0.1.1/tests/runtime/test_application_logging.py +216 -0
  218. watcherobot-0.1.1/tests/runtime/test_application_manifest.py +205 -0
  219. watcherobot-0.1.1/tests/runtime/test_application_manifest_metadata.py +72 -0
  220. watcherobot-0.1.1/tests/runtime/test_application_runtime.py +656 -0
  221. watcherobot-0.1.1/tests/runtime/test_application_session.py +88 -0
  222. watcherobot-0.1.1/tests/runtime/test_connection_identity.py +75 -0
  223. watcherobot-0.1.1/tests/runtime/test_control_rest.py +655 -0
  224. watcherobot-0.1.1/tests/runtime/test_daemon_entrypoint.py +106 -0
  225. watcherobot-0.1.1/tests/runtime/test_daemon_logging.py +38 -0
  226. watcherobot-0.1.1/tests/runtime/test_daemon_runtime_routing.py +633 -0
  227. watcherobot-0.1.1/tests/runtime/test_external_websocket_routing.py +273 -0
  228. watcherobot-0.1.1/tests/runtime/test_face_tracking_preview_broker.py +181 -0
  229. watcherobot-0.1.1/tests/runtime/test_face_tracking_udp_feedback.py +44 -0
  230. watcherobot-0.1.1/tests/runtime/test_face_tracking_udp_protocol.py +143 -0
  231. watcherobot-0.1.1/tests/runtime/test_face_tracking_udp_service.py +164 -0
  232. watcherobot-0.1.1/tests/runtime/test_frozen_entry.py +27 -0
  233. watcherobot-0.1.1/tests/runtime/test_full_architecture_flow.py +235 -0
  234. watcherobot-0.1.1/tests/runtime/test_lan_pairing_protocol.py +200 -0
  235. watcherobot-0.1.1/tests/runtime/test_maintenance_extensions.py +906 -0
  236. watcherobot-0.1.1/tests/runtime/test_maintenance_service.py +567 -0
  237. watcherobot-0.1.1/tests/runtime/test_pairing_session.py +311 -0
  238. watcherobot-0.1.1/tests/runtime/test_pairing_udp.py +328 -0
  239. watcherobot-0.1.1/tests/runtime/test_pairing_udp_interfaces.py +468 -0
  240. watcherobot-0.1.1/tests/runtime/test_pairing_websocket_gate.py +193 -0
  241. watcherobot-0.1.1/tests/runtime/test_runtime_instance.py +45 -0
  242. watcherobot-0.1.1/tests/runtime/test_sd_resource_contract_end_to_end.py +168 -0
  243. watcherobot-0.1.1/tests/runtime/test_standalone_application_routing_contract.py +97 -0
  244. watcherobot-0.1.1/tests/test_api.py +827 -0
  245. watcherobot-0.1.1/tests/test_app_cli_usability.py +296 -0
  246. watcherobot-0.1.1/tests/test_audio.py +41 -0
  247. watcherobot-0.1.1/tests/test_cli_runtime.py +390 -0
  248. watcherobot-0.1.1/tests/test_contract_vectors.py +53 -0
  249. watcherobot-0.1.1/tests/test_documentation.py +149 -0
  250. watcherobot-0.1.1/tests/test_examples.py +147 -0
  251. watcherobot-0.1.1/tests/test_face_tracking.py +232 -0
  252. watcherobot-0.1.1/tests/test_inputs.py +165 -0
  253. watcherobot-0.1.1/tests/test_job.py +61 -0
  254. watcherobot-0.1.1/tests/test_media.py +219 -0
  255. watcherobot-0.1.1/tests/test_opus.py +31 -0
  256. watcherobot-0.1.1/tests/test_protocol.py +71 -0
  257. watcherobot-0.1.1/tests/test_release.py +210 -0
  258. watcherobot-0.1.1/tests/test_removed_legacy_api.py +12 -0
  259. watcherobot-0.1.1/tests/test_robot_onboarding.py +413 -0
  260. watcherobot-0.1.1/tests/test_sdk_media_lab.py +1584 -0
  261. watcherobot-0.1.1/tests/tools/test_check_release_availability.py +88 -0
  262. watcherobot-0.1.1/tests/tools/test_check_release_gate.py +126 -0
  263. watcherobot-0.1.1/tests/tools/test_check_release_version.py +78 -0
  264. watcherobot-0.1.1/tests/tools/test_prepare_release.py +86 -0
  265. watcherobot-0.1.1/tools/ble_provisioning_hardware_test.py +147 -0
  266. watcherobot-0.1.1/tools/check_release_availability.py +85 -0
  267. watcherobot-0.1.1/tools/check_release_gate.py +195 -0
  268. watcherobot-0.1.1/tools/check_release_version.py +101 -0
  269. watcherobot-0.1.1/tools/prepare_release.py +91 -0
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """Bridge one GitHub review job to the Luxiao Hermes profile safely."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+
15
+ MAX_DIFF_CHARS = 100_000
16
+ REMOTE_REVIEW_TIMEOUT_SECONDS = 600
17
+ SSH_OPTIONS = ("-o", "StrictHostKeyChecking=yes", "-o", "BatchMode=yes")
18
+
19
+
20
+ def _write_result(path: Path, text: str) -> None:
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ path.write_text(text, encoding="utf-8")
23
+
24
+
25
+ def main() -> int:
26
+ if len(sys.argv) != 3:
27
+ print("Usage: luxiao_review.py <diff_path> <output_path>", file=sys.stderr)
28
+ return 2
29
+
30
+ diff_path = Path(sys.argv[1])
31
+ output_path = Path(sys.argv[2])
32
+ if not diff_path.is_file():
33
+ _write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n⚠️ Diff 文件不存在。")
34
+ return 1
35
+
36
+ hermes_host = os.environ.get("LUXIAO_HERMES_HOST", "").strip()
37
+ remote_script = os.environ.get(
38
+ "LUXIAO_REMOTE_SCRIPT", "/home/hermesadmin/scripts/luxiao-run.sh"
39
+ ).strip()
40
+ remote_dir = os.environ.get(
41
+ "LUXIAO_REMOTE_DIR", "/home/hermesadmin/.cache/luxiao-review"
42
+ ).strip()
43
+ if not hermes_host:
44
+ _write_result(
45
+ output_path,
46
+ "## 🤖 Luxiao PR 审查报告\n\n⚠️ Runner 未配置 LUXIAO_HERMES_HOST。",
47
+ )
48
+ return 1
49
+
50
+ full_diff = diff_path.read_text(encoding="utf-8", errors="replace")
51
+ diff_text = full_diff[:MAX_DIFF_CHARS]
52
+ truncation_notice = ""
53
+ if len(full_diff) > MAX_DIFF_CHARS:
54
+ omitted_chars = len(full_diff) - MAX_DIFF_CHARS
55
+ omitted_lines = full_diff[MAX_DIFF_CHARS:].count("\n")
56
+ truncation_notice = (
57
+ "\n\n> ⚠️ Diff 过大,本次输入已明确截断:"
58
+ f"省略 {omitted_chars} 个字符、约 {omitted_lines} 行。"
59
+ "审查结论必须注明未覆盖范围,不能宣称完成全量审查。"
60
+ )
61
+ if not diff_text.strip() or diff_text.strip() == "empty":
62
+ _write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n✅ 无代码变更。")
63
+ return 0
64
+
65
+ prompt = f"""请审查以下 Pull Request。按照你的审查框架(架构、产品、规范、损伤 + 意图分析 + Merge 建议)给出完整审查报告。
66
+
67
+ ## PR 信息
68
+
69
+ - 标题: {os.environ.get('PR_TITLE', '')}
70
+ - 描述: {os.environ.get('PR_BODY', '')}
71
+ - 变更: {os.environ.get('PR_FILES', '')} 个文件, +{os.environ.get('PR_ADDITIONS', '')} / -{os.environ.get('PR_DELETIONS', '')}
72
+
73
+ ## 代码 Diff
74
+
75
+ {diff_text}{truncation_notice}
76
+
77
+ 请直接输出审查报告,不要多余的前缀。"""
78
+
79
+ runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir()))
80
+ runner_temp.mkdir(parents=True, exist_ok=True)
81
+ remote_file = f"{remote_dir}/{uuid.uuid4().hex}.txt"
82
+ with tempfile.NamedTemporaryFile(
83
+ mode="w", encoding="utf-8", prefix="luxiao-prompt-", suffix=".txt", dir=runner_temp, delete=False
84
+ ) as prompt_file:
85
+ prompt_file.write(prompt)
86
+ local_file = Path(prompt_file.name)
87
+
88
+ try:
89
+ subprocess.run(
90
+ ["ssh", *SSH_OPTIONS, hermes_host, "mkdir", "-p", remote_dir],
91
+ check=True,
92
+ capture_output=True,
93
+ text=True,
94
+ timeout=30,
95
+ )
96
+ subprocess.run(
97
+ ["scp", *SSH_OPTIONS, str(local_file), f"{hermes_host}:{remote_file}"],
98
+ check=True,
99
+ capture_output=True,
100
+ text=True,
101
+ timeout=30,
102
+ )
103
+ result = subprocess.run(
104
+ [
105
+ "ssh",
106
+ *SSH_OPTIONS,
107
+ hermes_host,
108
+ "timeout",
109
+ "--signal=TERM",
110
+ "--kill-after=30s",
111
+ f"{REMOTE_REVIEW_TIMEOUT_SECONDS}s",
112
+ shlex.quote(remote_script),
113
+ shlex.quote(remote_file),
114
+ ],
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=REMOTE_REVIEW_TIMEOUT_SECONDS + 60,
118
+ )
119
+ finally:
120
+ local_file.unlink(missing_ok=True)
121
+ subprocess.run(
122
+ ["ssh", *SSH_OPTIONS, hermes_host, "rm", "-f", "--", remote_file],
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=30,
126
+ check=False,
127
+ )
128
+
129
+ if result.returncode != 0 or not result.stdout.strip():
130
+ _write_result(
131
+ output_path,
132
+ "## 🤖 Luxiao PR 审查报告\n\n"
133
+ f"⚠️ Luxiao Agent 调用失败(退出码 {result.returncode})。",
134
+ )
135
+ return 1
136
+
137
+ text = result.stdout
138
+ markers = ("🤖 PR 审查报告", "PR 审查报告", "## PR 审查", "## 审查报告")
139
+ for marker in markers:
140
+ if marker in text:
141
+ text = text[text.index(marker) :]
142
+ break
143
+ else:
144
+ text = text[-6000:]
145
+ _write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n" + text)
146
+ print(f"Review saved to {output_path}")
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
@@ -0,0 +1,52 @@
1
+ name: PR Review by Luxiao
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize, reopened, ready_for_review]
6
+
7
+ permissions:
8
+ contents: read
9
+ pull-requests: write
10
+
11
+ jobs:
12
+ review:
13
+ runs-on: [self-hosted, Linux, X64, sdk-ci, pr-review]
14
+ if: github.event.pull_request.draft == false
15
+ steps:
16
+ - name: Get PR Diff via API
17
+ env:
18
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19
+ run: |
20
+ diff_file="${RUNNER_TEMP}/pr-${{ github.event.pull_request.number }}.diff"
21
+ gh pr diff ${{ github.event.pull_request.number }} -R ${{ github.repository }} > "${diff_file}" 2>/dev/null
22
+ if [ ! -s "${diff_file}" ]; then echo "empty" > "${diff_file}"; fi
23
+ echo "DIFF_FILE=${diff_file}" >> "${GITHUB_ENV}"
24
+ echo "Diff lines: $(wc -l < "${diff_file}")"
25
+
26
+ - name: Run Luxiao Review
27
+ env:
28
+ LUXIAO_HERMES_HOST: ${{ vars.LUXIAO_HERMES_HOST }}
29
+ PR_TITLE: ${{ github.event.pull_request.title }}
30
+ PR_BODY: ${{ github.event.pull_request.body }}
31
+ PR_ADDITIONS: ${{ github.event.pull_request.additions }}
32
+ PR_DELETIONS: ${{ github.event.pull_request.deletions }}
33
+ PR_FILES: ${{ github.event.pull_request.changed_files }}
34
+ run: |
35
+ python3 /home/runner-ci/scripts/luxiao-review.py \
36
+ "${DIFF_FILE}" \
37
+ "${RUNNER_TEMP}/review_result.md"
38
+
39
+ - name: Post Review Comment
40
+ if: always()
41
+ env:
42
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43
+ run: |
44
+ review_file="${RUNNER_TEMP}/review_result.md"
45
+ if [ ! -s "${review_file}" ]; then
46
+ echo "## 🤖 Luxiao PR 审查报告" > "${review_file}"
47
+ echo "" >> "${review_file}"
48
+ echo "⚠️ Review agent did not produce output." >> "${review_file}"
49
+ fi
50
+ gh pr comment ${{ github.event.pull_request.number }} \
51
+ -R ${{ github.repository }} \
52
+ --body "$(cat "${review_file}")"
@@ -0,0 +1,169 @@
1
+ name: Prepare SDK release
2
+
3
+ on:
4
+ pull_request:
5
+ types: [closed]
6
+ workflow_dispatch:
7
+ inputs:
8
+ target_version:
9
+ description: "显式 PEP 440 版本;飞书指令使用此字段"
10
+ required: false
11
+ type: string
12
+ release_type:
13
+ description: "未指定显式版本时使用的版本增量"
14
+ required: false
15
+ type: choice
16
+ options: [none, prerelease, stable, minor, major]
17
+ default: none
18
+ source:
19
+ description: "发布请求来源"
20
+ required: true
21
+ type: string
22
+
23
+ permissions:
24
+ contents: read
25
+
26
+ jobs:
27
+ prepare:
28
+ if: >-
29
+ github.event_name == 'workflow_dispatch' ||
30
+ (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main')
31
+ # Trusted orchestration never shares an execution identity with pull-request code.
32
+ runs-on: [self-hosted, Linux, X64, sdk-orchestrator]
33
+ steps:
34
+ - name: Resolve one explicit release request
35
+ id: release
36
+ env:
37
+ EVENT_NAME: ${{ github.event_name }}
38
+ LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }}
39
+ INPUT_TARGET: ${{ inputs.target_version }}
40
+ INPUT_TYPE: ${{ inputs.release_type }}
41
+ INPUT_SOURCE: ${{ inputs.source }}
42
+ SOURCE_PR: ${{ github.event.pull_request.number }}
43
+ SOURCE_TITLE: ${{ github.event.pull_request.title }}
44
+ shell: bash
45
+ run: |
46
+ python - <<'PY' >> "${GITHUB_OUTPUT}"
47
+ import json
48
+ import os
49
+
50
+ if os.environ["EVENT_NAME"] == "workflow_dispatch":
51
+ target = os.environ.get("INPUT_TARGET", "").strip()
52
+ release_type = os.environ.get("INPUT_TYPE", "").strip()
53
+ if release_type == "none":
54
+ release_type = ""
55
+ if bool(target) == bool(release_type):
56
+ raise SystemExit("Exactly one of target_version or release_type is required")
57
+ print(f"target={target}")
58
+ print(f"release_type={release_type}")
59
+ print(f"source={os.environ['INPUT_SOURCE'].strip()}")
60
+ print("skip=false")
61
+ else:
62
+ mapping = {
63
+ "release:prerelease": "prerelease",
64
+ "release:stable": "stable",
65
+ "release:minor": "minor",
66
+ "release:major": "major",
67
+ }
68
+ labels = json.loads(os.environ.get("LABELS_JSON") or "[]")
69
+ selected = [mapping[label] for label in labels if label in mapping]
70
+ if len(selected) > 1:
71
+ raise SystemExit("A merged PR may have only one release:* label")
72
+ if not selected:
73
+ print("skip=true")
74
+ else:
75
+ print("target=")
76
+ print(f"release_type={selected[0]}")
77
+ print(
78
+ "source=PR #{}:{}".format(
79
+ os.environ["SOURCE_PR"], os.environ["SOURCE_TITLE"].strip()
80
+ )
81
+ )
82
+ print("skip=false")
83
+ PY
84
+
85
+ - name: Mint repository-scoped GitHub App token
86
+ if: steps.release.outputs.skip == 'false'
87
+ id: app-token
88
+ uses: actions/create-github-app-token@v2
89
+ with:
90
+ app-id: ${{ secrets.ORULINK_RELEASE_APP_ID }}
91
+ private-key: ${{ secrets.ORULINK_RELEASE_APP_PRIVATE_KEY }}
92
+ owner: ${{ github.repository_owner }}
93
+ repositories: WatcheRobot_python_sdk
94
+
95
+ - name: Check out main
96
+ if: steps.release.outputs.skip == 'false'
97
+ uses: actions/checkout@v6
98
+ with:
99
+ ref: main
100
+ fetch-depth: 0
101
+ persist-credentials: true
102
+ token: ${{ steps.app-token.outputs.token }}
103
+
104
+ - uses: actions/setup-python@v6
105
+ if: steps.release.outputs.skip == 'false'
106
+ with:
107
+ python-version: "3.12"
108
+
109
+ - name: Prepare version branch and pull request
110
+ if: steps.release.outputs.skip == 'false'
111
+ env:
112
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
113
+ RELEASE_TYPE: ${{ steps.release.outputs.release_type }}
114
+ TARGET_VERSION: ${{ steps.release.outputs.target }}
115
+ RELEASE_SOURCE: ${{ steps.release.outputs.source }}
116
+ shell: bash
117
+ run: |
118
+ # `packaging` is part of the trusted Python tool cache provisioned on the
119
+ # orchestrator runner. Release preparation must not depend on a mutable
120
+ # package-index download before it can even validate the requested version.
121
+ python -c "from packaging.version import Version; Version('0.1.1a1')"
122
+ arguments=(--source "${RELEASE_SOURCE}")
123
+ if [[ -n "${TARGET_VERSION}" ]]; then
124
+ arguments+=(--target "${TARGET_VERSION}")
125
+ else
126
+ arguments+=(--release-type "${RELEASE_TYPE}")
127
+ fi
128
+ version=$(python tools/prepare_release.py "${arguments[@]}")
129
+ python tools/check_release_availability.py "${version}" --repository "${GITHUB_REPOSITORY}"
130
+ branch="release/watcherobot-${version}"
131
+ existing=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${branch}" --state open --json number --jq 'length')
132
+ if [[ "${existing}" != "0" ]]; then
133
+ echo "Release PR already exists for ${version}; nothing to do."
134
+ exit 0
135
+ fi
136
+ git switch -c "${branch}"
137
+ git config user.name "orulink-release-bot"
138
+ git config user.email "release-bot@users.noreply.github.com"
139
+ git add src/watcherobot/__init__.py CHANGELOG.md
140
+ git commit -m "chore(release): 准备发布 watcherobot ${version}" \
141
+ -m "由 ${RELEASE_SOURCE} 触发。此提交仅更新 SDK 唯一版本源和中文更新日志,正式发布仍需版本 PR 审查、标签门禁、TestPyPI 验证与 GitHub Environment 人工批准。"
142
+ git push origin "${branch}"
143
+ body=$(cat <<EOF
144
+ ## 发布来源
145
+
146
+ - 来源:${RELEASE_SOURCE}
147
+ - 发布类型:${RELEASE_TYPE:-显式版本}
148
+ - 目标版本:${version}
149
+
150
+ ## 门禁
151
+
152
+ - 本 PR 必须人工审查和合并
153
+ - 合并后由陆骁创建 annotated tag
154
+ - Tag 构建先发布并验证 TestPyPI
155
+ - 正式 PyPI 发布需要 GitHub Environment 人工批准
156
+
157
+ ## 已知风险与实机状态
158
+
159
+ - 本 PR 只准备版本元数据,不代表已完成实机验收
160
+ - 稳定版发布前必须补充 RTC 实机验证结果
161
+ EOF
162
+ )
163
+ gh pr create \
164
+ --repo "${GITHUB_REPOSITORY}" \
165
+ --base main \
166
+ --head "${branch}" \
167
+ --title "chore(release): 发布 watcherobot ${version}" \
168
+ --label "release:version" \
169
+ --body "${body}"
@@ -0,0 +1,288 @@
1
+ name: Release Python package
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ concurrency:
8
+ group: sdk-release-${{ github.ref }}
9
+ cancel-in-progress: false
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ start-clean:
16
+ name: Clean release workspace before use
17
+ runs-on: [self-hosted, Linux, X64, sdk-release]
18
+ steps:
19
+ - name: Remove files left by a previous release job
20
+ shell: bash
21
+ run: |
22
+ workspace=$(realpath -m "${GITHUB_WORKSPACE}")
23
+ [[ "${workspace}" == /opt/actions-runner-release/_work/* ]] || {
24
+ echo "Refusing to clean unexpected workspace: ${workspace}" >&2
25
+ exit 1
26
+ }
27
+ shopt -s dotglob nullglob
28
+ rm -rf -- "${workspace}"/*
29
+
30
+ gate:
31
+ name: Validate release request
32
+ needs: start-clean
33
+ runs-on: [self-hosted, Linux, X64, sdk-release]
34
+ outputs:
35
+ version: ${{ steps.gate.outputs.version }}
36
+ prerelease: ${{ steps.gate.outputs.prerelease }}
37
+ commit: ${{ steps.gate.outputs.commit }}
38
+ reuse-artifact: ${{ steps.gate.outputs.reuse-artifact }}
39
+ permissions:
40
+ contents: read
41
+ pull-requests: read
42
+ steps:
43
+ - uses: actions/checkout@v6
44
+ with:
45
+ fetch-depth: 0
46
+ persist-credentials: false
47
+ - uses: actions/setup-python@v6
48
+ with:
49
+ python-version: "3.12"
50
+ cache: pip
51
+ - run: python -m pip install --upgrade packaging
52
+ - id: gate
53
+ name: Validate tag, main ancestry, version PR and registries
54
+ env:
55
+ GH_TOKEN: ${{ github.token }}
56
+ run: |
57
+ tag_commit=$(git rev-list -n 1 "${GITHUB_REF_NAME}")
58
+ gate_json=$(python tools/check_release_gate.py \
59
+ --repository "${GITHUB_REPOSITORY}" \
60
+ --tag "${GITHUB_REF_NAME}" \
61
+ --sha "${tag_commit}")
62
+ version=$(python -c 'import json,sys; print(json.load(sys.stdin)["version"])' <<< "${gate_json}")
63
+ reuse_artifact=$(python -c 'import json,sys; print(str(json.load(sys.stdin)["reuse_artifact"]).lower())' <<< "${gate_json}")
64
+ echo "version=${version}" >> "${GITHUB_OUTPUT}"
65
+ echo "commit=${tag_commit}" >> "${GITHUB_OUTPUT}"
66
+ echo "reuse-artifact=${reuse_artifact}" >> "${GITHUB_OUTPUT}"
67
+ python tools/check_release_version.py "${GITHUB_REF_NAME}"
68
+ python -c 'from packaging.version import Version; import sys; print(f"prerelease={str(Version(sys.argv[1]).is_prerelease).lower()}")' "${version}" >> "${GITHUB_OUTPUT}"
69
+
70
+ build:
71
+ name: Build immutable distributions
72
+ needs: gate
73
+ runs-on: [self-hosted, Linux, X64, sdk-release]
74
+ steps:
75
+ - uses: actions/checkout@v6
76
+ if: needs.gate.outputs.reuse-artifact != 'true'
77
+ with:
78
+ fetch-depth: 0
79
+ persist-credentials: false
80
+ - uses: actions/setup-python@v6
81
+ if: needs.gate.outputs.reuse-artifact != 'true'
82
+ with:
83
+ python-version: "3.12"
84
+ cache: pip
85
+ - if: needs.gate.outputs.reuse-artifact != 'true'
86
+ run: python -m pip install --upgrade pip build twine
87
+ - if: needs.gate.outputs.reuse-artifact != 'true'
88
+ run: python -m pip install -e ".[test]"
89
+ - if: needs.gate.outputs.reuse-artifact != 'true'
90
+ run: python -m pytest
91
+ - if: needs.gate.outputs.reuse-artifact != 'true'
92
+ run: python -m mypy src/watcherobot
93
+ - if: needs.gate.outputs.reuse-artifact != 'true'
94
+ run: python -m build
95
+ - if: needs.gate.outputs.reuse-artifact != 'true'
96
+ run: python -m twine check dist/*
97
+ - name: Verify wheel installation
98
+ if: needs.gate.outputs.reuse-artifact != 'true'
99
+ run: |
100
+ python -m venv .venv-wheel-check
101
+ .venv-wheel-check/bin/python -m pip install --force-reinstall dist/*.whl
102
+ .venv-wheel-check/bin/python -m pip check
103
+ .venv-wheel-check/bin/python -c "import watcherobot; assert watcherobot.__version__ == '${{ needs.gate.outputs.version }}'"
104
+ .venv-wheel-check/bin/watcherobot --help
105
+ - name: Record distribution hashes
106
+ if: needs.gate.outputs.reuse-artifact != 'true'
107
+ run: sha256sum dist/* > SHA256SUMS
108
+ - name: Recover immutable distributions from the matching draft Release
109
+ if: needs.gate.outputs.reuse-artifact == 'true'
110
+ env:
111
+ GH_TOKEN: ${{ github.token }}
112
+ run: |
113
+ mkdir -p dist
114
+ gh release download "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --pattern 'watcherobot-*' --dir dist
115
+ gh release download "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --pattern SHA256SUMS --dir .
116
+ sha256sum --check SHA256SUMS
117
+ - uses: actions/upload-artifact@v7
118
+ with:
119
+ name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }}
120
+ path: |
121
+ dist/
122
+ SHA256SUMS
123
+ if-no-files-found: error
124
+ retention-days: 30
125
+
126
+ draft-release:
127
+ name: Create draft GitHub Release
128
+ needs: [gate, build]
129
+ runs-on: [self-hosted, Linux, X64, sdk-release]
130
+ permissions:
131
+ contents: write
132
+ steps:
133
+ - uses: actions/download-artifact@v8
134
+ with:
135
+ name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }}
136
+ path: artifact/
137
+ - name: Create immutable draft Release
138
+ env:
139
+ GH_TOKEN: ${{ github.token }}
140
+ run: |
141
+ prerelease_flag=()
142
+ if [[ "${{ needs.gate.outputs.prerelease }}" == "true" ]]; then prerelease_flag=(--prerelease); fi
143
+ if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
144
+ echo "Matching draft Release already exists; reusing it."
145
+ else
146
+ gh release create "${GITHUB_REF_NAME}" artifact/dist/* artifact/SHA256SUMS \
147
+ --repo "${GITHUB_REPOSITORY}" \
148
+ --target "${{ needs.gate.outputs.commit }}" \
149
+ --title "watcherobot ${{ needs.gate.outputs.version }}" \
150
+ --generate-notes \
151
+ --draft \
152
+ "${prerelease_flag[@]}"
153
+ fi
154
+
155
+ publish-testpypi:
156
+ name: Publish to TestPyPI
157
+ needs: [gate, build, draft-release]
158
+ runs-on: [self-hosted, Linux, X64, sdk-release]
159
+ environment: testpypi
160
+ permissions:
161
+ id-token: write
162
+ steps:
163
+ - uses: actions/download-artifact@v8
164
+ with:
165
+ name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }}
166
+ path: artifact/
167
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
168
+ - name: Publish immutable distributions to TestPyPI with OIDC
169
+ env:
170
+ UV_PUBLISH_CHECK_URL: https://test.pypi.org/simple/
171
+ run: >-
172
+ uv publish
173
+ --trusted-publishing always
174
+ --publish-url https://test.pypi.org/legacy/
175
+ artifact/dist/*
176
+
177
+ verify-testpypi:
178
+ name: Verify TestPyPI installation
179
+ needs: [gate, build, publish-testpypi]
180
+ runs-on: [self-hosted, Linux, X64, sdk-release]
181
+ steps:
182
+ - uses: actions/setup-python@v6
183
+ with:
184
+ python-version: "3.12"
185
+ - uses: actions/download-artifact@v8
186
+ with:
187
+ name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }}
188
+ path: artifact/
189
+ - name: Install exact release from TestPyPI
190
+ run: |
191
+ python -m venv .venv-testpypi
192
+ .venv-testpypi/bin/python -m pip install --index-url https://pypi.org/simple/ artifact/dist/*.whl
193
+ .venv-testpypi/bin/python -m pip uninstall -y watcherobot
194
+ for attempt in {1..12}; do
195
+ if .venv-testpypi/bin/python -m pip download \
196
+ --index-url https://test.pypi.org/simple/ \
197
+ --no-deps --only-binary=:all: --dest testpypi-download \
198
+ "watcherobot==${{ needs.gate.outputs.version }}"; then
199
+ break
200
+ fi
201
+ if [[ "${attempt}" == "12" ]]; then exit 1; fi
202
+ sleep 10
203
+ done
204
+ expected_wheel=$(find artifact/dist -maxdepth 1 -name '*.whl' -printf '%f\n')
205
+ test "$(sha256sum "testpypi-download/${expected_wheel}" | cut -d' ' -f1)" = \
206
+ "$(sha256sum "artifact/dist/${expected_wheel}" | cut -d' ' -f1)"
207
+ .venv-testpypi/bin/python -m pip install --no-deps "testpypi-download/${expected_wheel}"
208
+ .venv-testpypi/bin/python -m pip check
209
+ .venv-testpypi/bin/python -c "import watcherobot; assert watcherobot.__version__ == '${{ needs.gate.outputs.version }}'"
210
+ .venv-testpypi/bin/watcherobot --help
211
+
212
+ publish-pypi:
213
+ name: Publish to PyPI
214
+ needs: [gate, build, verify-testpypi, draft-release]
215
+ runs-on: [self-hosted, Linux, X64, sdk-release]
216
+ environment:
217
+ name: pypi
218
+ url: https://pypi.org/p/watcherobot
219
+ permissions:
220
+ id-token: write
221
+ if: needs.gate.outputs.prerelease == 'false'
222
+ steps:
223
+ - uses: actions/download-artifact@v8
224
+ with:
225
+ name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }}
226
+ path: artifact/
227
+ - name: Verify artifact hashes before production upload
228
+ run: (cd artifact && sha256sum --check SHA256SUMS)
229
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
230
+ - name: Publish the original distributions to PyPI with OIDC
231
+ run: uv publish --trusted-publishing always artifact/dist/*
232
+
233
+ verify-pypi:
234
+ name: Verify PyPI and publish GitHub Release
235
+ needs: [gate, publish-pypi]
236
+ runs-on: [self-hosted, Linux, X64, sdk-release]
237
+ permissions:
238
+ contents: write
239
+ if: needs.gate.outputs.prerelease == 'false'
240
+ steps:
241
+ - uses: actions/setup-python@v6
242
+ with:
243
+ python-version: "3.12"
244
+ - name: Install exact release from PyPI
245
+ run: |
246
+ python -m venv .venv-pypi
247
+ for attempt in {1..12}; do
248
+ if .venv-pypi/bin/python -m pip install "watcherobot==${{ needs.gate.outputs.version }}"; then break; fi
249
+ if [[ "${attempt}" == "12" ]]; then exit 1; fi
250
+ sleep 10
251
+ done
252
+ .venv-pypi/bin/python -m pip check
253
+ .venv-pypi/bin/python -c "import watcherobot; assert watcherobot.__version__ == '${{ needs.gate.outputs.version }}'"
254
+ .venv-pypi/bin/watcherobot --help
255
+ - name: Publish verified GitHub Release
256
+ env:
257
+ GH_TOKEN: ${{ github.token }}
258
+ run: gh release edit "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --draft=false
259
+
260
+ finalize-prerelease:
261
+ name: Publish verified TestPyPI prerelease
262
+ needs: [gate, verify-testpypi]
263
+ if: needs.gate.outputs.prerelease == 'true'
264
+ runs-on: [self-hosted, Linux, X64, sdk-release]
265
+ permissions:
266
+ contents: write
267
+ steps:
268
+ - name: Publish GitHub prerelease after TestPyPI verification
269
+ env:
270
+ GH_TOKEN: ${{ github.token }}
271
+ run: gh release edit "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --draft=false --prerelease
272
+
273
+ finish-clean:
274
+ name: Clean release workspace after use
275
+ if: always()
276
+ needs: [gate, build, draft-release, publish-testpypi, verify-testpypi, publish-pypi, verify-pypi, finalize-prerelease]
277
+ runs-on: [self-hosted, Linux, X64, sdk-release]
278
+ steps:
279
+ - name: Remove checked-out code, environments and downloaded artifacts
280
+ shell: bash
281
+ run: |
282
+ workspace=$(realpath -m "${GITHUB_WORKSPACE}")
283
+ [[ "${workspace}" == /opt/actions-runner-release/_work/* ]] || {
284
+ echo "Refusing to clean unexpected workspace: ${workspace}" >&2
285
+ exit 1
286
+ }
287
+ shopt -s dotglob nullglob
288
+ rm -rf -- "${workspace}"/*