splent-cli 1.28.2__tar.gz → 1.29.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 (201) hide show
  1. {splent_cli-1.28.2/src/splent_cli.egg-info → splent_cli-1.29.1}/PKG-INFO +1 -1
  2. {splent_cli-1.28.2 → splent_cli-1.29.1}/pyproject.toml +1 -1
  3. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_release.py +96 -1
  4. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_build.py +42 -5
  5. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_create.py +12 -1
  6. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_deploy.py +45 -1
  7. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_env.py +9 -2
  8. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_release.py +123 -58
  9. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_sync_template.py +11 -1
  10. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/release/release_resume.py +16 -9
  11. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/tokens_setup.py +4 -1
  12. splent_cli-1.29.1/src/splent_cli/services/image_registry.py +173 -0
  13. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/marketplace.py +69 -1
  14. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/registry.py +100 -0
  15. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/release.py +64 -10
  16. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/release_gate.py +107 -18
  17. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/git_url.py +21 -3
  18. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/template_drift.py +25 -1
  19. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/toml_edit.py +48 -0
  20. {splent_cli-1.28.2 → splent_cli-1.29.1/src/splent_cli.egg-info}/PKG-INFO +1 -1
  21. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli.egg-info/SOURCES.txt +1 -0
  22. {splent_cli-1.28.2 → splent_cli-1.29.1}/LICENSE +0 -0
  23. {splent_cli-1.28.2 → splent_cli-1.29.1}/README.md +0 -0
  24. {splent_cli-1.28.2 → splent_cli-1.29.1}/setup.cfg +0 -0
  25. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/__init__.py +0 -0
  26. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/__main__.py +0 -0
  27. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/cli.py +0 -0
  28. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/__init__.py +0 -0
  29. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/cache/__init__.py +0 -0
  30. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/cache/cache_clear.py +0 -0
  31. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/cache/cache_orphans.py +0 -0
  32. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/cache/cache_outdated.py +0 -0
  33. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/cache/cache_prune.py +0 -0
  34. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/cache/cache_size.py +0 -0
  35. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/cache/cache_status.py +0 -0
  36. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/cache/cache_usage.py +0 -0
  37. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/cache/cache_versions.py +0 -0
  38. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/__init__.py +0 -0
  39. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_contracts.py +0 -0
  40. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_deps.py +0 -0
  41. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_docker.py +0 -0
  42. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_env.py +0 -0
  43. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_features.py +0 -0
  44. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_github.py +0 -0
  45. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_infra.py +0 -0
  46. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_product.py +0 -0
  47. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_pypi.py +0 -0
  48. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_pyproject.py +0 -0
  49. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/check/check_releases.py +0 -0
  50. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/clear/__init__.py +0 -0
  51. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/clear/clear_build.py +0 -0
  52. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/clear/clear_log.py +0 -0
  53. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/clear/clear_uploads.py +0 -0
  54. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/command/__init__.py +0 -0
  55. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/command/command_create.py +0 -0
  56. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/coverage.py +0 -0
  57. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/database/db_console.py +0 -0
  58. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/database/db_dump.py +0 -0
  59. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/database/db_migrate.py +0 -0
  60. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/database/db_reset.py +0 -0
  61. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/database/db_restore.py +0 -0
  62. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/database/db_rollback.py +0 -0
  63. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/database/db_seed.py +0 -0
  64. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/database/db_status.py +0 -0
  65. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/database/db_upgrade.py +0 -0
  66. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/doctor.py +0 -0
  67. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/env/env_list.py +0 -0
  68. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/env/env_set.py +0 -0
  69. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/env/env_show.py +0 -0
  70. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/export/__init__.py +0 -0
  71. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/export/export_puml.py +0 -0
  72. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_add.py +0 -0
  73. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_attach.py +0 -0
  74. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_clean.py +0 -0
  75. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_clone.py +0 -0
  76. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_compat.py +0 -0
  77. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_compile.py +0 -0
  78. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_contract.py +0 -0
  79. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_create.py +0 -0
  80. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_delete.py +0 -0
  81. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_detach.py +0 -0
  82. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_discard.py +0 -0
  83. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_drift.py +0 -0
  84. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_env.py +0 -0
  85. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_fork.py +0 -0
  86. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_git.py +0 -0
  87. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_hook_add.py +0 -0
  88. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_hook_remove.py +0 -0
  89. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_hooks.py +0 -0
  90. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_impact.py +0 -0
  91. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_info.py +0 -0
  92. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_inject_config.py +0 -0
  93. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_install.py +0 -0
  94. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_list.py +0 -0
  95. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_order.py +0 -0
  96. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_outdated.py +0 -0
  97. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_pin.py +0 -0
  98. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_pip_install.py +0 -0
  99. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_pull.py +0 -0
  100. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_refine.py +0 -0
  101. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_remove.py +0 -0
  102. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_rename.py +0 -0
  103. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_search.py +0 -0
  104. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_status.py +0 -0
  105. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_sync_template.py +0 -0
  106. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_test.py +0 -0
  107. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_translate.py +0 -0
  108. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_unlock.py +0 -0
  109. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_upgrade.py +0 -0
  110. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_versions.py +0 -0
  111. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/feature/feature_xray.py +0 -0
  112. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/lint.py +0 -0
  113. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/locust.py +0 -0
  114. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/marketplace/login.py +0 -0
  115. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/marketplace/logout.py +0 -0
  116. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/marketplace/marketplace_auth_utils.py +0 -0
  117. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/marketplace/marketplace_index.py +0 -0
  118. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/marketplace/whoami.py +0 -0
  119. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/__init__.py +0 -0
  120. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_auto_require.py +0 -0
  121. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_clean.py +0 -0
  122. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_commands.py +0 -0
  123. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_config.py +0 -0
  124. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_configure.py +0 -0
  125. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_console.py +0 -0
  126. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_containers.py +0 -0
  127. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_derive.py +0 -0
  128. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_deselect.py +0 -0
  129. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_down.py +0 -0
  130. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_drift.py +0 -0
  131. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_list.py +0 -0
  132. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_logs.py +0 -0
  133. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_missing.py +0 -0
  134. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_port.py +0 -0
  135. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_resolve.py +0 -0
  136. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_restart.py +0 -0
  137. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_routes.py +0 -0
  138. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_run.py +0 -0
  139. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_select.py +0 -0
  140. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_shell.py +0 -0
  141. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_signals.py +0 -0
  142. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_test.py +0 -0
  143. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_up.py +0 -0
  144. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/product/product_validate.py +0 -0
  145. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/release/__init__.py +0 -0
  146. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/release/release_core.py +0 -0
  147. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/selenium.py +0 -0
  148. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/__init__.py +0 -0
  149. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_add_constraints.py +0 -0
  150. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_add_feature.py +0 -0
  151. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_compare.py +0 -0
  152. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_configurations.py +0 -0
  153. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_create.py +0 -0
  154. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_deps.py +0 -0
  155. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_features.py +0 -0
  156. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_fetch.py +0 -0
  157. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_info.py +0 -0
  158. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_list.py +0 -0
  159. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_outdated.py +0 -0
  160. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_pin.py +0 -0
  161. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_publish.py +0 -0
  162. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_remove_feature.py +0 -0
  163. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/spl/spl_utils.py +0 -0
  164. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/uvl/uvl_utils.py +0 -0
  165. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/commands/version.py +0 -0
  166. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/__init__.py +0 -0
  167. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/compose.py +0 -0
  168. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/context.py +0 -0
  169. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/credentials.py +0 -0
  170. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/index_refresh.py +0 -0
  171. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/marketplace_api.py +0 -0
  172. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/marketplace_url.py +0 -0
  173. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/preflight.py +0 -0
  174. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/pypi_quota.py +0 -0
  175. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/release_dry_run.py +0 -0
  176. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/services/spl_store.py +0 -0
  177. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/__init__.py +0 -0
  178. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/archetype.py +0 -0
  179. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/cache_utils.py +0 -0
  180. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/command_loader.py +0 -0
  181. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/contract_freshness.py +0 -0
  182. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/db_client.py +0 -0
  183. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/db_utils.py +0 -0
  184. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/decorators.py +0 -0
  185. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/dynamic_imports.py +0 -0
  186. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/feature_installer.py +0 -0
  187. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/feature_utils.py +0 -0
  188. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/forge.py +0 -0
  189. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/integrity.py +0 -0
  190. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/io_utils.py +0 -0
  191. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/lifecycle.py +0 -0
  192. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/manifest.py +0 -0
  193. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/path_utils.py +0 -0
  194. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/proc.py +0 -0
  195. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/redact.py +0 -0
  196. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/refinement_facts.py +0 -0
  197. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli/utils/upgrade_plan.py +0 -0
  198. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli.egg-info/dependency_links.txt +0 -0
  199. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli.egg-info/entry_points.txt +0 -0
  200. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli.egg-info/requires.txt +0 -0
  201. {splent_cli-1.28.2 → splent_cli-1.29.1}/src/splent_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splent_cli
3
- Version: 1.28.2
3
+ Version: 1.29.1
4
4
  Summary: SPLENT-CLI is a CLI to be able to work on your development more easily.
5
5
  Author-email: DiversoLab <diversolab@us.es>
6
6
  Project-URL: Homepage, https://github.com/diverso-lab/splent_cli
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splent_cli"
7
- version = "1.28.2"
7
+ version = "1.29.1"
8
8
  description = "SPLENT-CLI is a CLI to be able to work on your development more easily."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -3,6 +3,7 @@ feature:release — Release a feature: bump version, tag, publish to the git for
3
3
  """
4
4
 
5
5
  import ast
6
+ import json
6
7
  import os
7
8
  import re
8
9
  import shutil
@@ -363,6 +364,83 @@ def _extract_commands(commands_path: Path) -> list[str]:
363
364
  return sorted(set(re.findall(r"""@click\.command\s*\(\s*['"]([^'"]+)['"]""", text)))
364
365
 
365
366
 
367
+ def _call_name(node) -> str:
368
+ """``click.command`` for the decorator call ``@click.command(...)``."""
369
+ func = node.func if isinstance(node, ast.Call) else node
370
+ parts = []
371
+ while isinstance(func, ast.Attribute):
372
+ parts.append(func.attr)
373
+ func = func.value
374
+ if isinstance(func, ast.Name):
375
+ parts.append(func.id)
376
+ return ".".join(reversed(parts))
377
+
378
+
379
+ def _literal(node):
380
+ try:
381
+ return ast.literal_eval(node)
382
+ except (ValueError, SyntaxError, TypeError):
383
+ return None
384
+
385
+
386
+ def _first_line(text) -> str:
387
+ for line in (text or "").strip().splitlines():
388
+ if line.strip():
389
+ return line.strip()
390
+ return ""
391
+
392
+
393
+ def _extract_command_help(commands_path: Path) -> dict:
394
+ """What each command a feature adds is for, and how it is called.
395
+
396
+ ``{name: {"usage": "EMAIL ROLE [OPTIONS]", "help": "..."}}``, read from
397
+ the source without importing it: the arguments in the order they are
398
+ declared, in click's uppercase, ``[OPTIONS]`` when there are any, and the
399
+ first line of ``short_help``, ``help`` or the docstring. The marketplace
400
+ shows it next to each command, which used to be a bare name.
401
+ """
402
+ if not commands_path.exists():
403
+ return {}
404
+ try:
405
+ tree = ast.parse(commands_path.read_text())
406
+ except SyntaxError:
407
+ return {}
408
+ found = {}
409
+ for node in ast.walk(tree):
410
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
411
+ continue
412
+ name, help_text, usage, has_options = None, "", [], False
413
+ for deco in node.decorator_list:
414
+ if not isinstance(deco, ast.Call):
415
+ continue
416
+ kind = _call_name(deco)
417
+ if kind.endswith("command") and deco.args:
418
+ name = _literal(deco.args[0])
419
+ kwargs = {k.arg: _literal(k.value) for k in deco.keywords}
420
+ help_text = kwargs.get("short_help") or kwargs.get("help") or ""
421
+ elif kind.endswith("argument") and deco.args:
422
+ arg = _literal(deco.args[0])
423
+ if not isinstance(arg, str):
424
+ continue
425
+ kwargs = {k.arg: _literal(k.value) for k in deco.keywords}
426
+ metavar = arg.upper().replace("-", "_")
427
+ if kwargs.get("nargs") == -1:
428
+ metavar += "..."
429
+ optional = kwargs.get("required") is False or "default" in kwargs
430
+ usage.append(f"[{metavar}]" if optional else metavar)
431
+ elif kind.endswith("option"):
432
+ has_options = True
433
+ if not isinstance(name, str):
434
+ continue
435
+ if has_options:
436
+ usage.append("[OPTIONS]")
437
+ found[name] = {
438
+ "usage": " ".join(usage),
439
+ "help": _first_line(help_text) or _first_line(ast.get_docstring(node)),
440
+ }
441
+ return dict(sorted(found.items()))
442
+
443
+
366
444
  def _feature_sources(src_dir: Path):
367
445
  """The feature's own Python files, without its tests.
368
446
 
@@ -693,6 +771,7 @@ def infer_contract(
693
771
  templates = _extract_templates(src_dir)
694
772
  template_hook_slots = _extract_template_hook_slots(src_dir)
695
773
  commands = _extract_commands(src_dir / "commands.py")
774
+ commands_help = _extract_command_help(src_dir / "commands.py")
696
775
  docker = _extract_docker(feature_root)
697
776
  docker_contract = _extract_docker_contract(feature_root)
698
777
  req_features, env_vars = _scan_dependencies(src_dir, feature_name)
@@ -751,6 +830,7 @@ def infer_contract(
751
830
  "blueprints": blueprints,
752
831
  "models": models,
753
832
  "commands": commands,
833
+ "commands_help": commands_help,
754
834
  "hooks": hooks,
755
835
  "services": services,
756
836
  "signals": signals_provided,
@@ -945,7 +1025,8 @@ def write_contract(pyproject_path: str, contract: dict, feature_name: str) -> No
945
1025
  f"signals = {_toml_value(contract.get('signals', []))}\n"
946
1026
  f"translations = {_toml_value(contract.get('translations', []))}\n"
947
1027
  f"docker = {_toml_value(contract['docker'])}\n"
948
- "\n"
1028
+ + _commands_help_block(contract.get("commands_help") or {})
1029
+ + "\n"
949
1030
  "[tool.splent.contract.requires]\n"
950
1031
  f"features = {_toml_value(contract['requires_features'])}\n"
951
1032
  + (
@@ -989,6 +1070,20 @@ def write_contract(pyproject_path: str, contract: dict, feature_name: str) -> No
989
1070
  path.write_text(text + contract_block + refinement_block)
990
1071
 
991
1072
 
1073
+ def _commands_help_block(commands_help: dict) -> str:
1074
+ """The ``[tool.splent.contract.provides.commands_help]`` table, or nothing."""
1075
+ if not commands_help:
1076
+ return ""
1077
+ lines = ["\n[tool.splent.contract.provides.commands_help]\n"]
1078
+ for name, entry in commands_help.items():
1079
+ key = name if re.fullmatch(r"[A-Za-z0-9_-]+", name) else json.dumps(name)
1080
+ lines.append(
1081
+ f"{key} = {{ usage = {json.dumps(entry.get('usage', ''))}, "
1082
+ f"help = {json.dumps(entry.get('help', ''))} }}\n"
1083
+ )
1084
+ return "".join(lines)
1085
+
1086
+
992
1087
  # ── Compile assets ────────────────────────────────────────────────────
993
1088
 
994
1089
 
@@ -88,6 +88,16 @@ OPTIONAL_DEPLOY_SETTINGS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
88
88
  "trusted proxy. true forces it on every cookie, false never sets it.",
89
89
  ),
90
90
  ),
91
+ (
92
+ "SPLENT_IMAGE",
93
+ "git.splent.io/<org>/<product>:stable",
94
+ (
95
+ "Image the web service runs. Unset means the image built on this",
96
+ "host. Set it to the image a release publishes, tagged stable, to",
97
+ "run releases without building here. Every deploy pulls it, and the",
98
+ "autodeploy feature follows it.",
99
+ ),
100
+ ),
91
101
  (
92
102
  "MAX_CONTENT_LENGTH",
93
103
  "64M",
@@ -551,9 +561,34 @@ def _apply_restart_policy(svc_def: dict) -> None:
551
561
  svc_def["restart"] = RESTART_POLICY
552
562
 
553
563
 
564
+ def resolve_product_placeholders(value: str, product: str, *, prod: bool) -> str:
565
+ """Resolve ``__PRODUCT__`` in a feature's value for this product.
566
+
567
+ ``__PRODUCT___web`` is the product's web container, which is
568
+ ``<product>_web`` in development and ``<product>_web_deploy`` in
569
+ production (the prod compose template names it). Every other
570
+ ``__PRODUCT__`` is the product name. product:build and
571
+ product:env --merge --prod both write .env.deploy, so both go through
572
+ this: the merge used to replace the plain name only and point a feature
573
+ at the development container's name in production.
574
+ """
575
+ if "__PRODUCT__" not in value:
576
+ return value
577
+ if prod:
578
+ value = value.replace("__PRODUCT___web", f"{product}_web_deploy")
579
+ return value.replace("__PRODUCT__", product)
580
+
581
+
582
+ def local_or_registry_image(product: str) -> str:
583
+ """The web image of a deploy: ``SPLENT_IMAGE`` when set, else the local build."""
584
+ return "${SPLENT_IMAGE:-" + product + ":latest}"
585
+
586
+
554
587
  def apply_production_policies(compose_data: dict, product: str) -> dict:
555
588
  """Give every deployed service a restart policy, and the web and database
556
- services a healthcheck, unless the compose file already set one.
589
+ services a healthcheck, unless the compose file already set one. The web
590
+ image becomes ``${SPLENT_IMAGE:-<product>:latest}``, so a host can run the
591
+ image a release published instead of building its own.
557
592
 
558
593
  Applied by product:build to the merged deploy compose, so every product
559
594
  gets it on its next build, whatever its docker-compose.prod.yml says and
@@ -570,6 +605,11 @@ def apply_production_policies(compose_data: dict, product: str) -> dict:
570
605
  )
571
606
  if web_name and isinstance(services[web_name], dict):
572
607
  services[web_name].setdefault("healthcheck", dict(WEB_HEALTHCHECK))
608
+ # The local build, unless .env.deploy names a registry image. Only the
609
+ # plain local name is rewritten: an image the product chose on purpose
610
+ # is left alone.
611
+ if services[web_name].get("image") == f"{product}:latest":
612
+ services[web_name]["image"] = local_or_registry_image(product)
573
613
 
574
614
  db_name = next(
575
615
  (n for n in (f"{product}_db_deploy", f"{product}_db") if n in services),
@@ -1109,10 +1149,7 @@ def product_build(no_image, skip_preflight):
1109
1149
  # share the product network, so the prod service cannot simply take the
1110
1150
  # dev name as an alias. The CLI owns both names, so it owns the mapping.
1111
1151
  for k, v in env_result.items():
1112
- if "__PRODUCT__" in v:
1113
- env_result[k] = v.replace(
1114
- "__PRODUCT___web", f"{product}_web_deploy"
1115
- ).replace("__PRODUCT__", product)
1152
+ env_result[k] = resolve_product_placeholders(v, product, prod=True)
1116
1153
 
1117
1154
  # Remove __FEATURE_HOST_DIR__ variables — not needed in prod
1118
1155
  # (templates are baked into Docker images, no bind mounts)
@@ -13,7 +13,12 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
13
13
 
14
14
  from splent_cli.utils.path_utils import PathUtils
15
15
  from splent_cli.services import context, spl_store
16
- from splent_cli.utils.template_drift import is_reserved_repo_name, not_a_product_reason
16
+ from splent_cli.utils.template_drift import (
17
+ DEFAULT_UPGRADE_SCHEDULE,
18
+ is_reserved_repo_name,
19
+ not_a_product_reason,
20
+ )
21
+ from splent_cli.utils.template_drift import _forge_host as _template_forge_host
17
22
 
18
23
  try:
19
24
  from importlib.metadata import version as _pkg_version
@@ -218,6 +223,10 @@ def make_product(name, spl_name, spl_doi, features_file, force):
218
223
  # Dropping it here made every product created this way fall back to
219
224
  # "<name>.uvl" and 404, with nothing local left that knew the real name.
220
225
  "spl_file": pin.file or "",
226
+ # The forge workflows. The same values product:sync-template --ci
227
+ # renders, so a new product does not start out drifted.
228
+ "forge_host": _template_forge_host(),
229
+ "upgrade_schedule": DEFAULT_UPGRADE_SCHEDULE,
221
230
  # Production persistence. These switch on the named-volume mounts in
222
231
  # the prod compose template so uploads survive redeploys. Products
223
232
  # scaffolded before these variables existed render without them and
@@ -304,6 +313,8 @@ def _write_product(env, name, base_path, template_ctx):
304
313
  "scripts/05_0_start_app_dev.sh": "product/product_05_0_start_app_dev.sh.j2",
305
314
  "scripts/05_1_start_app_prod.sh": "product/product_05_1_start_app_prod.sh.j2",
306
315
  ".gitignore": "product/product_.gitignore.j2",
316
+ ".forgejo/workflows/splent-validate.yml": "product/product_ci_validate.yml.j2",
317
+ ".forgejo/workflows/splent-upgrade.yml": "product/product_ci_upgrade.yml.j2",
307
318
  "LICENSE": "product/product_LICENSE.j2",
308
319
  "package.json": "product/product_package.json.j2",
309
320
  "pyproject.toml": "product/product_pyproject.toml.j2",
@@ -441,9 +441,18 @@ def product_deploy(down, ci):
441
441
  )
442
442
  continue
443
443
  if ci:
444
- env_value = os.getenv(key)
444
+ # The environment first. Without it, whatever the interactive
445
+ # deploy would offer as a default is taken (a random string
446
+ # for a secret, the conventional database names), so a feature
447
+ # that ships a <SET> secret does not stop an unattended deploy.
448
+ # What only the operator knows still has to come from them.
449
+ env_value = os.getenv(key) or suggest_value(key, product)
445
450
  if env_value:
446
451
  env_vars[key] = env_value
452
+ if not os.getenv(key):
453
+ click.echo(
454
+ click.style(" secret ", dim=True) + f"{key} generated"
455
+ )
447
456
  else:
448
457
  missing_vars.append(key)
449
458
  else:
@@ -624,6 +633,41 @@ def product_deploy(down, ci):
624
633
  # container starts.
625
634
  compose.ensure_network(compose.network_name(product))
626
635
 
636
+ # A registry image (SPLENT_IMAGE) is pulled first: Compose only pulls an
637
+ # image it does not have, so a host that already ran an older build of
638
+ # the same tag would otherwise redeploy that one.
639
+ image = (env_vars.get("SPLENT_IMAGE") or "").strip()
640
+ if "/" in image:
641
+ click.echo(click.style(" image ", dim=True) + f"pulling {image}")
642
+ pulled = subprocess.run(
643
+ [
644
+ "docker",
645
+ "compose",
646
+ "-p",
647
+ compose.deploy_project_name(product),
648
+ "-f",
649
+ compose_path,
650
+ "--env-file",
651
+ env_path,
652
+ "pull",
653
+ f"{product}_web_deploy",
654
+ ],
655
+ capture_output=True,
656
+ text=True,
657
+ cwd=docker_dir,
658
+ env={**os.environ, **host_docker_dir_env(workspace, product)},
659
+ )
660
+ if pulled.returncode != 0:
661
+ click.secho(f" Could not pull {image}.", fg="red")
662
+ detail = (pulled.stderr or pulled.stdout or "").strip().splitlines()
663
+ for line in detail[-5:]:
664
+ click.echo(f" {line}")
665
+ click.echo(
666
+ " Check SPLENT_IMAGE in docker/.env.deploy, and docker login to "
667
+ "that registry when the image is private."
668
+ )
669
+ raise SystemExit(1)
670
+
627
671
  click.echo(click.style(" deploying ", dim=True) + f"{product} (prod)...")
628
672
 
629
673
  try:
@@ -368,11 +368,18 @@ def product_env(generate, merge, env_name, process_all):
368
368
  # second product of the same line came up.
369
369
  merged["SPLENT_NETWORK"] = compose.network_name(product)
370
370
 
371
- # Replace __PRODUCT__ placeholder with the actual product name
371
+ # Replace __PRODUCT__ placeholder with the actual product name.
372
+ # product_build imports this module, so the helper is imported here.
373
+ from splent_cli.commands.product.product_build import (
374
+ resolve_product_placeholders,
375
+ )
376
+
372
377
  product_replaced = []
373
378
  for k, v in merged.items():
374
379
  if "__PRODUCT__" in v:
375
- merged[k] = v.replace("__PRODUCT__", product)
380
+ merged[k] = resolve_product_placeholders(
381
+ v, product, prod=env_name == "prod"
382
+ )
376
383
  product_replaced.append(k)
377
384
 
378
385
  if env_name == "prod":
@@ -1,16 +1,19 @@
1
1
  """
2
- product:release — Release a product with version bump, tag, GitHub/PyPI/Docker Hub.
2
+ product:release, a product release: version bump, tag, forge, PyPI and the image.
3
3
  """
4
4
 
5
+ import contextlib
6
+ import json
5
7
  import os
6
8
  import subprocess
9
+ import tempfile
7
10
 
8
11
  import click
9
12
  from splent_cli.commands.product.product_build import (
10
13
  build_production_image,
11
14
  production_dockerfile,
12
15
  )
13
- from splent_cli.services import context, release, release_gate
16
+ from splent_cli.services import context, image_registry, release, release_gate
14
17
  from splent_cli.utils.proc import run, require_docker
15
18
 
16
19
 
@@ -51,19 +54,73 @@ def _guard_all_features_pinned(product_path: str):
51
54
  raise SystemExit(1)
52
55
 
53
56
 
54
- def release_docker_image(product: str, version: str, docker_dir: str):
55
- """Build the production image and push it to Docker Hub.
57
+ def release_targets(product: str, version: str, product_path: str, org: str):
58
+ """The registries this release pushes the image to, from the product's pyproject."""
59
+ try:
60
+ registries, mirrors = image_registry.declared_registries(
61
+ os.path.join(product_path, "pyproject.toml")
62
+ )
63
+ except image_registry.RegistryDeclarationError as e:
64
+ click.secho(f" error: {e}", fg="red")
65
+ raise SystemExit(1)
66
+ return image_registry.image_targets(product, version, org, registries, mirrors)
67
+
68
+
69
+ #: Docker Hub answers to these names in a client's configuration.
70
+ _DOCKER_HUB_HOSTS = ("index.docker.io", "registry-1.docker.io", "docker.io")
71
+
72
+
73
+ @contextlib.contextmanager
74
+ def registry_session(hosts=()):
75
+ """A docker client configuration of its own for one release's logins and pushes.
76
+
77
+ The host's configuration may hand credentials to a helper, the macOS
78
+ keychain under Docker Desktop, and that helper refuses to store a login
79
+ for a host the keychain already holds a git password for ("The specified
80
+ item already exists in the keychain"), which stopped the marketplace's
81
+ first release. A configuration of its own also keeps the release tokens
82
+ off the machine: it is deleted when the release is done.
83
+ """
84
+ # An empty credsStore is not enough: the docker client then detects the
85
+ # platform's helper again. An empty helper per host is what makes it keep
86
+ # the login in this file.
87
+ helpers = {host: "" for host in (*hosts, *_DOCKER_HUB_HOSTS) if host}
88
+ with tempfile.TemporaryDirectory(prefix="splent-docker-") as config_dir:
89
+ with open(os.path.join(config_dir, "config.json"), "w") as handle:
90
+ json.dump({"auths": {}, "credHelpers": helpers}, handle)
91
+ yield {**os.environ, "DOCKER_CONFIG": config_dir}
92
+
93
+
94
+ def _login(target, env=None) -> bool:
95
+ user, secret = target.credentials()
96
+ if not user or not secret:
97
+ return False
98
+ command = ["docker", "login", "-u", user, "--password-stdin"]
99
+ if target.host:
100
+ command.insert(2, target.host)
101
+ try:
102
+ run(command, input=secret, capture=True, env=env)
103
+ except subprocess.CalledProcessError:
104
+ return False
105
+ return True
106
+
107
+
108
+ def release_docker_image(product: str, version: str, docker_dir: str, targets):
109
+ """Build the production image once and push it to every registry.
56
110
 
57
111
  Public because release:resume has to be able to finish this channel too:
58
112
  the image is pushed last, after the forge and PyPI are already
59
113
  irreversible.
60
114
 
61
115
  The image is the one product:build makes, docker/Dockerfile.<product>.prod
62
- built with the workspace as context, so what Docker Hub serves is what a
63
- deploy runs. Building ``docker_dir`` with no Dockerfile named would look
64
- for docker/Dockerfile, which products do not have. build_production_image
65
- stages the SPL's UVL model first, so the pushed image ships the model the
66
- product pins even from a clean clone where product:build never ran.
116
+ built with the workspace as context, so what a registry serves is what a
117
+ deploy runs. build_production_image stages the SPL's UVL model first, so
118
+ the pushed image ships the model the product pins even from a clean clone
119
+ where product:build never ran.
120
+
121
+ Each registry gets the version tag and the ``stable`` channel tag (Docker
122
+ Hub also ``latest``). A required registry that refuses the login or the
123
+ push stops the release here; a mirror that does is reported and skipped.
67
124
  """
68
125
  require_docker()
69
126
 
@@ -76,50 +133,52 @@ def release_docker_image(product: str, version: str, docker_dir: str):
76
133
  )
77
134
  raise SystemExit(1)
78
135
 
79
- username = os.getenv("DOCKERHUB_USERNAME")
80
- password = os.getenv("DOCKERHUB_PASSWORD")
81
- missing = [
82
- name
83
- for name, value in (
84
- ("DOCKERHUB_USERNAME", username),
85
- ("DOCKERHUB_PASSWORD", password),
86
- )
87
- if not value
88
- ]
89
- if missing:
90
- click.secho(
91
- " error: missing Docker Hub credentials: "
92
- + ", ".join(missing)
93
- + "\n Set them in your environment (e.g. export DOCKERHUB_USERNAME=... "
94
- "DOCKERHUB_PASSWORD=...) before releasing.",
95
- fg="red",
96
- )
97
- raise SystemExit(1)
98
-
99
- image_name = f"{username}/{product}"
100
-
101
- click.echo(" docker logging into Docker Hub...")
102
- run(
103
- ["docker", "login", "-u", username, "--password-stdin"],
104
- input=password,
105
- capture=True,
106
- )
107
-
108
- click.echo(" docker building image...")
109
- try:
110
- build_production_image(
111
- product,
112
- str(context.workspace()),
113
- docker_dir,
114
- [f"{image_name}:{version}", f"{image_name}:latest"],
115
- )
116
- except subprocess.CalledProcessError:
117
- click.secho(" error: Docker image build failed.", fg="red")
118
- raise SystemExit(1)
119
-
120
- click.echo(" docker pushing images...")
121
- run(["docker", "push", f"{image_name}:{version}"])
122
- run(["docker", "push", f"{image_name}:latest"])
136
+ with registry_session([t.host for t in targets]) as docker_env:
137
+ usable = []
138
+ for target in targets:
139
+ click.echo(f" docker logging into {target.label}...")
140
+ if _login(target, docker_env):
141
+ usable.append(target)
142
+ continue
143
+ if target.mirror:
144
+ click.secho(
145
+ f" docker the {target.label} mirror is left out, its login "
146
+ f"failed or {' / '.join(target.credential_names())} are not set",
147
+ fg="yellow",
148
+ )
149
+ continue
150
+ user_env, secret_env = target.credential_names()
151
+ click.secho(
152
+ f" error: could not log into {target.label}. Check {user_env} and "
153
+ f"{secret_env}.",
154
+ fg="red",
155
+ )
156
+ raise SystemExit(1)
157
+
158
+ refs = [ref for target in usable for ref in target.refs]
159
+ click.echo(" docker building image...")
160
+ try:
161
+ build_production_image(product, str(context.workspace()), docker_dir, refs)
162
+ except subprocess.CalledProcessError:
163
+ click.secho(" error: Docker image build failed.", fg="red")
164
+ raise SystemExit(1)
165
+
166
+ for target in usable:
167
+ click.echo(f" docker pushing to {target.label}...")
168
+ try:
169
+ for ref in target.refs:
170
+ run(["docker", "push", ref], env=docker_env)
171
+ except subprocess.CalledProcessError:
172
+ if target.mirror:
173
+ click.secho(
174
+ f" docker the push to the {target.label} mirror failed "
175
+ "and is left out",
176
+ fg="yellow",
177
+ )
178
+ continue
179
+ click.secho(f" error: the push to {target.label} failed.", fg="red")
180
+ raise SystemExit(1)
181
+ click.echo(f" docker {', '.join(target.refs)}")
123
182
 
124
183
  click.echo(" docker push complete")
125
184
 
@@ -139,7 +198,7 @@ def _left_out_flags(channels) -> list[str]:
139
198
 
140
199
  @click.command(
141
200
  "product:release",
142
- short_help="Release a product: version bump, tag, forge/PyPI/Docker Hub.",
201
+ short_help="Release a product: version bump, tag, forge, PyPI and image registries.",
143
202
  )
144
203
  @click.argument("version", required=False, default=None)
145
204
  @click.option("--product", default=None, help="Override SPLENT_APP.")
@@ -210,7 +269,7 @@ def product_release(
210
269
  raise SystemExit(1)
211
270
  org, repo_name = parts
212
271
 
213
- # Docker Hub is a third channel, and it is the one that publishes LAST, so
272
+ # The image is a third channel, and it is the one that publishes LAST, so
214
273
  # it is the one most able to diverge. It goes through the gate with the
215
274
  # others instead of being discovered at the end.
216
275
  channels = release.resolve_channels(
@@ -250,7 +309,12 @@ def product_release(
250
309
 
251
310
  def _docker_hook(_path, ver):
252
311
  if release_gate.DOCKER in channels:
253
- release_docker_image(product, ver, docker_dir)
312
+ release_docker_image(
313
+ product,
314
+ ver,
315
+ docker_dir,
316
+ release_targets(product, ver, product_path, org),
317
+ )
254
318
 
255
319
  release.run_release_pipeline(
256
320
  product,
@@ -262,8 +326,9 @@ def product_release(
262
326
  channels=channels,
263
327
  resume_target=resume_target,
264
328
  allow_unfinished=allow_unfinished,
265
- docker_image=f"{os.getenv('DOCKERHUB_USERNAME', '')}/{product}".lstrip("/")
266
- or None,
329
+ image_targets=release_targets(product, version, product_path, org)
330
+ if release_gate.DOCKER in channels
331
+ else None,
267
332
  post_pypi_hook=_docker_hook,
268
333
  wait=wait,
269
334
  )
@@ -37,6 +37,12 @@ from splent_cli.utils.template_drift import (
37
37
  is_flag=True,
38
38
  help="Update only docker/ files.",
39
39
  )
40
+ @click.option(
41
+ "--ci",
42
+ "only_ci",
43
+ is_flag=True,
44
+ help="Update only the forge workflows in .forgejo/workflows/.",
45
+ )
40
46
  @click.option(
41
47
  "--dry-run",
42
48
  is_flag=True,
@@ -48,6 +54,7 @@ def product_sync_template(
48
54
  only_scripts,
49
55
  only_entrypoints,
50
56
  only_docker,
57
+ only_ci,
51
58
  dry_run,
52
59
  yes,
53
60
  ):
@@ -60,6 +67,7 @@ def product_sync_template(
60
67
  splent product:sync-template myapp
61
68
  splent product:sync-template myapp --scripts
62
69
  splent product:sync-template myapp --docker
70
+ splent product:sync-template myapp --ci
63
71
  splent product:sync-template myapp --dry-run
64
72
  splent product:sync-template myapp --yes
65
73
  """
@@ -72,7 +80,7 @@ def product_sync_template(
72
80
  product_path = resolve_product_dir(workspace, product_name)
73
81
 
74
82
  # Select which groups to process
75
- filter_active = only_scripts or only_entrypoints or only_docker
83
+ filter_active = only_scripts or only_entrypoints or only_docker or only_ci
76
84
  groups_to_run: dict[str, dict] = {}
77
85
  if not filter_active or only_scripts:
78
86
  groups_to_run["scripts"] = PRODUCT_GROUPS["scripts"]
@@ -80,6 +88,8 @@ def product_sync_template(
80
88
  groups_to_run["entrypoints"] = PRODUCT_GROUPS["entrypoints"]
81
89
  if not filter_active or only_docker:
82
90
  groups_to_run["docker"] = PRODUCT_GROUPS["docker"]
91
+ if not filter_active or only_ci:
92
+ groups_to_run["ci"] = PRODUCT_GROUPS["ci"]
83
93
 
84
94
  ctx = product_ctx(product_name)
85
95