mcp-openstack-ops 3.5.4__tar.gz → 3.5.6__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 (166) hide show
  1. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.github/workflows/pypi-publish.yml +36 -10
  2. {mcp_openstack_ops-3.5.4/src/mcp_openstack_ops.egg-info → mcp_openstack_ops-3.5.6}/PKG-INFO +15 -2
  3. mcp_openstack_ops-3.5.4/PKG-INFO → mcp_openstack_ops-3.5.6/README.md +13 -18
  4. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/pyproject.toml +1 -1
  5. mcp_openstack_ops-3.5.6/scripts/check_release.py +72 -0
  6. mcp_openstack_ops-3.5.6/scripts/publish_release.py +141 -0
  7. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/connection.py +6 -3
  8. mcp_openstack_ops-3.5.4/README.md → mcp_openstack_ops-3.5.6/src/mcp_openstack_ops.egg-info/PKG-INFO +31 -0
  9. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops.egg-info/SOURCES.txt +5 -1
  10. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops.egg-info/requires.txt +1 -1
  11. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops.egg-info/scm_file_list.json +4 -0
  12. mcp_openstack_ops-3.5.6/src/mcp_openstack_ops.egg-info/scm_version.json +8 -0
  13. mcp_openstack_ops-3.5.6/tests/test_publish_release.py +153 -0
  14. mcp_openstack_ops-3.5.6/tests/test_release.py +57 -0
  15. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/uv.lock +4 -4
  16. mcp_openstack_ops-3.5.4/src/mcp_openstack_ops.egg-info/scm_version.json +0 -8
  17. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.env.example +0 -0
  18. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.env.test +0 -0
  19. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.github/FUNDING.yml +0 -0
  20. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.github/copilot-instructions.md +0 -0
  21. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.github/dependabot.yml +0 -0
  22. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.gitignore +0 -0
  23. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.gitleaks.toml +0 -0
  24. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.pre-commit-config.yaml +0 -0
  25. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/.python-version +0 -0
  26. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/Dockerfile.MCP-Server +0 -0
  27. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/Dockerfile.MCPO-Proxy +0 -0
  28. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/LICENSE +0 -0
  29. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/MANIFEST.in +0 -0
  30. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/build-mcp-server-docker-image.sh +0 -0
  31. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/build-mcpo-server-docker-image.sh +0 -0
  32. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/diagnose_openstack.py +0 -0
  33. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/docker-compose.yml +0 -0
  34. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/img/MCP-Workflow-of-Quickstart-Tutorial.png +0 -0
  35. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/img/screenshot-claude-desktop.png +0 -0
  36. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/img/screenshot-claude-desktop.png:Zone.Identifier +0 -0
  37. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/img/screenshot-example-cluster-status.png +0 -0
  38. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/img/screenshot-openstack-dashboard.png +0 -0
  39. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/main.py +0 -0
  40. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/mcp-config.json.http +0 -0
  41. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/mcp-config.json.multi-project +0 -0
  42. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/mcp-config.json.stdio +0 -0
  43. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/scripts/mcp-server-docker-cmd.sh +0 -0
  44. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/scripts/run-mcp-inspector-local.sh +0 -0
  45. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/scripts/run-mcp-inspector-pypi.sh +0 -0
  46. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/setup.cfg +0 -0
  47. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/__init__.py +0 -0
  48. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/__main__.py +0 -0
  49. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/functions.py +0 -0
  50. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/mcp_main.py +0 -0
  51. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/prompt_template.md +0 -0
  52. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/__init__.py +0 -0
  53. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/compute.py +0 -0
  54. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/core.py +0 -0
  55. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/identity.py +0 -0
  56. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/image.py +0 -0
  57. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/load_balancer/__init__.py +0 -0
  58. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/load_balancer/amphorae.py +0 -0
  59. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/load_balancer/core.py +0 -0
  60. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/load_balancer/health_monitors.py +0 -0
  61. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/load_balancer/l7_policies.py +0 -0
  62. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/load_balancer/listeners.py +0 -0
  63. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/load_balancer/management.py +0 -0
  64. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/load_balancer/pools.py +0 -0
  65. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/monitoring.py +0 -0
  66. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/network.py +0 -0
  67. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/orchestration.py +0 -0
  68. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/services/storage.py +0 -0
  69. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/__init__.py +0 -0
  70. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_availability_zones.py +0 -0
  71. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_floating_ip_pools.py +0 -0
  72. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_floating_ips.py +0 -0
  73. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_heat_stacks.py +0 -0
  74. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_hypervisor_details.py +0 -0
  75. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_image_detail_list.py +0 -0
  76. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_instance.py +0 -0
  77. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_instance_by_name.py +0 -0
  78. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_instance_details.py +0 -0
  79. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_instances_by_status.py +0 -0
  80. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_keypair_list.py +0 -0
  81. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_amphorae.py +0 -0
  82. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_availability_zones.py +0 -0
  83. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_details.py +0 -0
  84. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_flavors.py +0 -0
  85. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_health_monitors.py +0 -0
  86. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_l7_policies.py +0 -0
  87. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_l7_rules.py +0 -0
  88. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_list.py +0 -0
  89. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_listeners.py +0 -0
  90. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_pool_members.py +0 -0
  91. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_pools.py +0 -0
  92. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_providers.py +0 -0
  93. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_load_balancer_quotas.py +0 -0
  94. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_network_details.py +0 -0
  95. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_project_details.py +0 -0
  96. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_quota.py +0 -0
  97. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_resource_monitoring.py +0 -0
  98. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_role_assignments.py +0 -0
  99. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_routers.py +0 -0
  100. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_security_groups.py +0 -0
  101. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_server_events.py +0 -0
  102. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_server_groups.py +0 -0
  103. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_server_volumes.py +0 -0
  104. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_service_status.py +0 -0
  105. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_usage_statistics.py +0 -0
  106. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_user_list.py +0 -0
  107. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_volume_list.py +0 -0
  108. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_volume_snapshots.py +0 -0
  109. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/get_volume_types.py +0 -0
  110. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/search_instances.py +0 -0
  111. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_alarms.py +0 -0
  112. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_compute_agents.py +0 -0
  113. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_domains.py +0 -0
  114. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_flavor.py +0 -0
  115. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_floating_ip.py +0 -0
  116. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_floating_ip_port_forwarding.py +0 -0
  117. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_heat_stack.py +0 -0
  118. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_identity_groups.py +0 -0
  119. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_image.py +0 -0
  120. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_image_members.py +0 -0
  121. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_image_metadata.py +0 -0
  122. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_image_visibility.py +0 -0
  123. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_instance.py +0 -0
  124. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_keypair.py +0 -0
  125. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer.py +0 -0
  126. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_amphora.py +0 -0
  127. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_availability_zone.py +0 -0
  128. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_flavor.py +0 -0
  129. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_health_monitor.py +0 -0
  130. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_l7_policy.py +0 -0
  131. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_l7_rule.py +0 -0
  132. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_listener.py +0 -0
  133. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_pool.py +0 -0
  134. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_pool_member.py +0 -0
  135. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_load_balancer_quota.py +0 -0
  136. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_metrics.py +0 -0
  137. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_network_agents.py +0 -0
  138. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_network_ports.py +0 -0
  139. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_network_qos_policies.py +0 -0
  140. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_networks.py +0 -0
  141. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_project.py +0 -0
  142. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_quota.py +0 -0
  143. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_roles.py +0 -0
  144. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_backup.py +0 -0
  145. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_dump.py +0 -0
  146. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_fixed_ip.py +0 -0
  147. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_floating_ip.py +0 -0
  148. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_group.py +0 -0
  149. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_migration.py +0 -0
  150. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_network.py +0 -0
  151. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_properties.py +0 -0
  152. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_security_group.py +0 -0
  153. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_server_volume.py +0 -0
  154. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_service_logs.py +0 -0
  155. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_services.py +0 -0
  156. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_snapshot.py +0 -0
  157. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_subnets.py +0 -0
  158. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_volume.py +0 -0
  159. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_volume_backups.py +0 -0
  160. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_volume_groups.py +0 -0
  161. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops/tools/set_volume_qos.py +0 -0
  162. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops.egg-info/dependency_links.txt +0 -0
  163. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops.egg-info/entry_points.txt +0 -0
  164. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/src/mcp_openstack_ops.egg-info/top_level.txt +0 -0
  165. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/test_compatibility.sh +0 -0
  166. {mcp_openstack_ops-3.5.4 → mcp_openstack_ops-3.5.6}/test_project_isolation.py +0 -0
@@ -1,7 +1,4 @@
1
- # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
2
- # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3
-
4
- name: Publish to PyPI on tag
1
+ name: Publish Packages and GitHub Release on Tag
5
2
 
6
3
  on:
7
4
  push:
@@ -9,7 +6,6 @@ on:
9
6
 
10
7
  permissions:
11
8
  contents: read
12
- id-token: write
13
9
 
14
10
  jobs:
15
11
  build:
@@ -22,14 +18,21 @@ jobs:
22
18
  - uses: actions/setup-python@v7
23
19
  with:
24
20
  python-version: "3.12.x"
25
- - name: Install build tool
26
- run: pip install --upgrade build
21
+ - name: Install build and release test tools
22
+ run: pip install --upgrade build pytest PyYAML
23
+ - name: Test release automation
24
+ run: python -m pytest -q tests/test_publish_release.py tests/test_release.py
27
25
  - name: Build distributions
28
26
  run: python -m build
27
+ - name: Verify tag and distribution versions
28
+ env:
29
+ RELEASE_TAG: ${{ github.ref_name }}
30
+ run: python -m scripts.check_release "$RELEASE_TAG" dist
29
31
  - uses: actions/upload-artifact@v7
30
32
  with:
31
33
  name: dist
32
34
  path: dist/
35
+ if-no-files-found: error
33
36
 
34
37
  publish-to-testpypi:
35
38
  name: Publish to TestPyPI
@@ -55,8 +58,7 @@ jobs:
55
58
  name: Publish to PyPI
56
59
  if: startsWith(github.ref, 'refs/tags/')
57
60
  runs-on: ubuntu-latest
58
- needs: build
59
- #needs: publish-to-testpypi
61
+ needs: publish-to-testpypi
60
62
  permissions:
61
63
  id-token: write
62
64
  environment:
@@ -70,4 +72,28 @@ jobs:
70
72
  - name: Publish to PyPI
71
73
  uses: pypa/gh-action-pypi-publish@release/v1
72
74
  with:
73
- repository-url: https://upload.pypi.org/legacy/
75
+ repository-url: https://upload.pypi.org/legacy/
76
+
77
+ release:
78
+ name: Publish GitHub Release
79
+ needs: publish-to-pypi
80
+ runs-on: ubuntu-latest
81
+ timeout-minutes: 5
82
+ permissions:
83
+ contents: write
84
+ concurrency:
85
+ group: github-release-${{ github.repository }}
86
+ cancel-in-progress: false
87
+ queue: max
88
+ env:
89
+ GH_TOKEN: ${{ github.token }}
90
+ RELEASE_TAG: ${{ github.ref_name }}
91
+ steps:
92
+ - name: Check Out Tagged Repository
93
+ uses: actions/checkout@v7
94
+ with:
95
+ persist-credentials: false
96
+ fetch-depth: 0
97
+
98
+ - name: Publish Release with Commit Messages
99
+ run: python3 -m scripts.publish_release "$RELEASE_TAG"
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-openstack-ops
3
- Version: 3.5.4
3
+ Version: 3.5.6
4
4
  Summary: OpenStack operations automation MCP server
5
5
  Author-email: JungJungIn <call518@gmail.com>
6
6
  Requires-Python: >=3.12
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
9
  Requires-Dist: fastmcp>=2.12.3
10
- Requires-Dist: openstacksdk<=4.20.0,>=4.1.0
10
+ Requires-Dist: openstacksdk<=4.21.0,>=4.1.0
11
11
  Requires-Dist: python-dotenv>=1.1.1
12
12
  Requires-Dist: setuptools-scm>=10.0.5
13
13
  Requires-Dist: pre-commit>=4.6.0
@@ -35,6 +35,19 @@ Dynamic: license-file
35
35
 
36
36
  [![Ask DeepWiki](https://img.shields.io/badge/Ask-DeepWiki-blue)](https://deepwiki.com/call518/MCP-OpenStack-Ops)
37
37
 
38
+ ## Package and GitHub Releases
39
+
40
+ Pushing a release tag runs the GitHub Actions publication workflow: build and
41
+ validate distributions, publish to TestPyPI, publish to PyPI, then create a GitHub
42
+ Release named after the tag. Tags must use `X.Y.Z` or `vX.Y.Z`, optionally followed
43
+ by `aN`, `bN`, or `rcN`; both distribution versions must match the tag.
44
+
45
+ Release notes include full commit messages since the most recently published
46
+ non-draft GitHub Release, in oldest-first topological order. The first Release
47
+ includes all commit history. Existing published Releases are left unchanged;
48
+ an existing draft or a previous Release outside the tag's ancestry stops release
49
+ creation for manual review. Prereleases are marked as such and are not made latest.
50
+
38
51
  ---
39
52
 
40
53
  ## Features
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: mcp-openstack-ops
3
- Version: 3.5.4
4
- Summary: OpenStack operations automation MCP server
5
- Author-email: JungJungIn <call518@gmail.com>
6
- Requires-Python: >=3.12
7
- Description-Content-Type: text/markdown
8
- License-File: LICENSE
9
- Requires-Dist: fastmcp>=2.12.3
10
- Requires-Dist: openstacksdk<=4.20.0,>=4.1.0
11
- Requires-Dist: python-dotenv>=1.1.1
12
- Requires-Dist: setuptools-scm>=10.0.5
13
- Requires-Dist: pre-commit>=4.6.0
14
- Provides-Extra: dev
15
- Requires-Dist: pytest>=7.0.0; extra == "dev"
16
- Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
17
- Dynamic: license-file
18
-
19
1
  # MCP-OpenStack-Ops
20
2
 
21
3
  > **MCP OpenStack Operations Server**: A comprehensive MCP (Model Context Protocol) server providing OpenStack project management and monitoring capabilities with built-in safety controls and single-project scope.
@@ -35,6 +17,19 @@ Dynamic: license-file
35
17
 
36
18
  [![Ask DeepWiki](https://img.shields.io/badge/Ask-DeepWiki-blue)](https://deepwiki.com/call518/MCP-OpenStack-Ops)
37
19
 
20
+ ## Package and GitHub Releases
21
+
22
+ Pushing a release tag runs the GitHub Actions publication workflow: build and
23
+ validate distributions, publish to TestPyPI, publish to PyPI, then create a GitHub
24
+ Release named after the tag. Tags must use `X.Y.Z` or `vX.Y.Z`, optionally followed
25
+ by `aN`, `bN`, or `rcN`; both distribution versions must match the tag.
26
+
27
+ Release notes include full commit messages since the most recently published
28
+ non-draft GitHub Release, in oldest-first topological order. The first Release
29
+ includes all commit history. Existing published Releases are left unchanged;
30
+ an existing draft or a previous Release outside the tag's ancestry stops release
31
+ creation for manual review. Prereleases are marked as such and are not made latest.
32
+
38
33
  ---
39
34
 
40
35
  ## Features
@@ -7,7 +7,7 @@ requires-python = ">=3.12"
7
7
  authors = [{ name = "JungJungIn", email = "call518@gmail.com" }]
8
8
  dependencies = [
9
9
  "fastmcp>=2.12.3",
10
- "openstacksdk>=4.1.0,<=4.20.0",
10
+ "openstacksdk>=4.1.0,<=4.21.0",
11
11
  "python-dotenv>=1.1.1",
12
12
  "setuptools-scm>=10.0.5",
13
13
  "pre-commit>=4.6.0",
@@ -0,0 +1,72 @@
1
+ """Require both distribution metadata versions to match the release tag."""
2
+
3
+ import argparse
4
+ import re
5
+ import tarfile
6
+ import zipfile
7
+ from email.parser import BytesParser
8
+ from pathlib import Path
9
+
10
+
11
+ def release_version(tag: str) -> str:
12
+ """Validate a canonical release tag and return its package version."""
13
+ version = tag.removeprefix("v")
14
+ number = r"(?:0|[1-9][0-9]*)"
15
+ if not re.fullmatch(
16
+ rf"{number}\.{number}\.{number}(?:(?:a|b|rc){number})?", version
17
+ ):
18
+ raise ValueError("Use a tag such as 0.5.7, v0.5.7, or 0.5.7rc1")
19
+ return version
20
+
21
+
22
+ def check_release(tag: str, directory: Path) -> None:
23
+ """Validate a canonical release tag and the wheel/sdist prepared for upload."""
24
+ version = release_version(tag)
25
+ wheels = list(directory.glob("*.whl"))
26
+ sdists = list(directory.glob("*.tar.gz"))
27
+ if len(wheels) != 1 or len(sdists) != 1:
28
+ raise ValueError("Expected exactly one wheel and one source distribution")
29
+
30
+ with zipfile.ZipFile(wheels[0]) as archive:
31
+ metadata_files = [
32
+ name for name in archive.namelist() if name.endswith(".dist-info/METADATA")
33
+ ]
34
+ if len(metadata_files) != 1:
35
+ raise ValueError("Expected one wheel metadata file")
36
+ wheel_metadata = archive.read(metadata_files[0])
37
+ with tarfile.open(sdists[0], "r:gz") as archive:
38
+ metadata_files = [
39
+ item
40
+ for item in archive.getmembers()
41
+ if len(Path(item.name).parts) == 2 and item.name.endswith("/PKG-INFO")
42
+ ]
43
+ if len(metadata_files) != 1:
44
+ raise ValueError("Expected one source distribution metadata file")
45
+ stream = archive.extractfile(metadata_files[0])
46
+ if stream is None:
47
+ raise ValueError("Source distribution metadata is not a file")
48
+ with stream:
49
+ sdist_metadata = stream.read()
50
+
51
+ for path, raw in zip((wheels[0], sdists[0]), (wheel_metadata, sdist_metadata)):
52
+ metadata = BytesParser().parsebytes(raw)
53
+ if metadata.get("Name") != "mcp-openstack-ops" or metadata.get("Version") != version:
54
+ raise ValueError(f"{path.name}: expected mcp-openstack-ops {version}")
55
+ print(f"Verified {path.name}: mcp-openstack-ops {version}")
56
+
57
+
58
+ def main() -> None:
59
+ """Check release artifacts without uploading or modifying them."""
60
+ parser = argparse.ArgumentParser(description=__doc__)
61
+ parser.add_argument("tag")
62
+ parser.add_argument("directory", type=Path)
63
+ args = parser.parse_args()
64
+ try:
65
+ check_release(args.tag, args.directory)
66
+ except ValueError as exc:
67
+ parser.exit(1, f"Release validation failed: {exc}\n")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
72
+
@@ -0,0 +1,141 @@
1
+ """Publish a tag's GitHub Release after package publication has succeeded."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import quote
12
+
13
+ from scripts.check_release import release_version
14
+
15
+
16
+ def command(*args: str) -> str:
17
+ """Run an argument list without interpreting commit messages as shell code."""
18
+ return subprocess.run(
19
+ args, check=True, capture_output=True, text=True
20
+ ).stdout.strip()
21
+
22
+
23
+ def release_notes(repository: str, tag: str, previous: str | None) -> str:
24
+ """Collect full commit messages in oldest-first topological order."""
25
+ current = command("git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}")
26
+ revision = current
27
+ if previous:
28
+ base = command(
29
+ "git", "rev-parse", "--verify", f"refs/tags/{previous}^{{commit}}"
30
+ )
31
+ command("git", "merge-base", "--is-ancestor", base, current)
32
+ revision = f"{base}..{current}"
33
+ raw = command(
34
+ "git",
35
+ "log",
36
+ "--reverse",
37
+ "--topo-order",
38
+ "--format=%H%x00%B%x00",
39
+ revision,
40
+ "--",
41
+ )
42
+ parts = raw.split("\0")
43
+ notes = [
44
+ f"## {tag}",
45
+ f"Changes since {previous}." if previous else "Initial release.",
46
+ "",
47
+ ]
48
+ for index in range(0, len(parts) - 1, 2):
49
+ sha, message = parts[index].strip(), parts[index + 1].strip()
50
+ # A longer fence preserves messages containing Markdown code fences.
51
+ fence = "`" * max(
52
+ 3, max((len(m) + 1 for m in re.findall(r"`+", message)), default=3)
53
+ )
54
+ notes.extend(
55
+ [
56
+ f"### [{sha[:7]}](https://github.com/{repository}/commit/{sha})",
57
+ f"{fence}text",
58
+ message,
59
+ fence,
60
+ "",
61
+ ]
62
+ )
63
+ if previous:
64
+ notes.append(
65
+ f"[Full Changelog](https://github.com/{repository}/compare/{quote(previous, safe='')}...{quote(tag, safe='')})"
66
+ )
67
+ return "\n".join(notes) + "\n"
68
+
69
+
70
+ def publish_release(repository: str, tag: str) -> None:
71
+ """Create a release once, using the last published release as the baseline."""
72
+ version = release_version(tag)
73
+ current = command("git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}")
74
+ remote = command("gh", "api", f"repos/{repository}/commits/{tag}", "--jq", ".sha")
75
+ if current != remote or current != command("git", "rev-parse", "HEAD"):
76
+ raise ValueError(
77
+ "The remote tag, local tag, and checked-out commit must match."
78
+ )
79
+ pages = json.loads(
80
+ command(
81
+ "gh",
82
+ "api",
83
+ "--paginate",
84
+ "--slurp",
85
+ f"repos/{repository}/releases?per_page=100",
86
+ )
87
+ )
88
+ releases: list[dict[str, Any]] = [release for page in pages for release in page]
89
+ existing = next(
90
+ (release for release in releases if release["tag_name"] == tag), None
91
+ )
92
+ if existing:
93
+ if existing["draft"]:
94
+ raise ValueError("A draft already exists for this tag; review it manually.")
95
+ print(f"Release {tag} already exists; leaving its content unchanged.")
96
+ return
97
+ published = [release for release in releases if not release["draft"]]
98
+ previous = (
99
+ max(published, key=lambda release: release["published_at"])["tag_name"]
100
+ if published
101
+ else None
102
+ )
103
+ notes = release_notes(repository, tag, previous)
104
+ with tempfile.TemporaryDirectory(prefix="openstack-ops-release-") as temporary:
105
+ path = Path(temporary) / "release-notes.md"
106
+ path.write_text(notes, encoding="utf-8")
107
+ args = [
108
+ "gh",
109
+ "release",
110
+ "create",
111
+ tag,
112
+ "--repo",
113
+ repository,
114
+ "--verify-tag",
115
+ "--title",
116
+ tag,
117
+ "--notes-file",
118
+ str(path),
119
+ ]
120
+ if re.search(r"(?:a|b|rc)\d+$", version):
121
+ args.extend(["--prerelease", "--latest=false"])
122
+ command(*args)
123
+ print(f"Published GitHub Release {tag} (previous release: {previous or 'none'}).")
124
+
125
+
126
+ def main() -> None:
127
+ """Publish only when explicitly called by the final release job."""
128
+ parser = argparse.ArgumentParser(description=__doc__)
129
+ parser.add_argument("tag")
130
+ args = parser.parse_args()
131
+ try:
132
+ publish_release(os.environ["GITHUB_REPOSITORY"], args.tag)
133
+ except subprocess.CalledProcessError as error:
134
+ parser.exit(1, f"Release command failed: {error.stderr or error}\n")
135
+ except (ValueError, KeyError, OSError) as error:
136
+ parser.exit(1, f"GitHub Release failed: {error}\n")
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
141
+
@@ -152,10 +152,13 @@ def get_current_project_id() -> str:
152
152
  """
153
153
  try:
154
154
  conn = get_openstack_connection()
155
- # Get project ID from the token
155
+ # Get project ID from the token scope. conn.auth only holds the
156
+ # configured auth args, so it has no project_id when the connection
157
+ # is scoped by project name.
156
158
  token = conn.identity.get_token()
157
- project_id = conn.auth.get('project_id') or conn.auth.get('tenant_id')
158
-
159
+ project_id = conn.current_project_id or \
160
+ conn.auth.get('project_id') or conn.auth.get('tenant_id')
161
+
159
162
  if not project_id:
160
163
  # Fallback: get from project name
161
164
  project_name = os.environ.get("OS_PROJECT_NAME")
@@ -1,3 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-openstack-ops
3
+ Version: 3.5.6
4
+ Summary: OpenStack operations automation MCP server
5
+ Author-email: JungJungIn <call518@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: fastmcp>=2.12.3
10
+ Requires-Dist: openstacksdk<=4.21.0,>=4.1.0
11
+ Requires-Dist: python-dotenv>=1.1.1
12
+ Requires-Dist: setuptools-scm>=10.0.5
13
+ Requires-Dist: pre-commit>=4.6.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
16
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
17
+ Dynamic: license-file
18
+
1
19
  # MCP-OpenStack-Ops
2
20
 
3
21
  > **MCP OpenStack Operations Server**: A comprehensive MCP (Model Context Protocol) server providing OpenStack project management and monitoring capabilities with built-in safety controls and single-project scope.
@@ -17,6 +35,19 @@
17
35
 
18
36
  [![Ask DeepWiki](https://img.shields.io/badge/Ask-DeepWiki-blue)](https://deepwiki.com/call518/MCP-OpenStack-Ops)
19
37
 
38
+ ## Package and GitHub Releases
39
+
40
+ Pushing a release tag runs the GitHub Actions publication workflow: build and
41
+ validate distributions, publish to TestPyPI, publish to PyPI, then create a GitHub
42
+ Release named after the tag. Tags must use `X.Y.Z` or `vX.Y.Z`, optionally followed
43
+ by `aN`, `bN`, or `rcN`; both distribution versions must match the tag.
44
+
45
+ Release notes include full commit messages since the most recently published
46
+ non-draft GitHub Release, in oldest-first topological order. The first Release
47
+ includes all commit history. Existing published Releases are left unchanged;
48
+ an existing draft or a previous Release outside the tag's ancestry stops release
49
+ creation for manual review. Prereleases are marked as such and are not made latest.
50
+
20
51
  ---
21
52
 
22
53
  ## Features
@@ -30,7 +30,9 @@ img/screenshot-claude-desktop.png
30
30
  img/screenshot-claude-desktop.png:Zone.Identifier
31
31
  img/screenshot-example-cluster-status.png
32
32
  img/screenshot-openstack-dashboard.png
33
+ scripts/check_release.py
33
34
  scripts/mcp-server-docker-cmd.sh
35
+ scripts/publish_release.py
34
36
  scripts/run-mcp-inspector-local.sh
35
37
  scripts/run-mcp-inspector-pypi.sh
36
38
  src/mcp_openstack_ops/__init__.py
@@ -156,4 +158,6 @@ src/mcp_openstack_ops/tools/set_subnets.py
156
158
  src/mcp_openstack_ops/tools/set_volume.py
157
159
  src/mcp_openstack_ops/tools/set_volume_backups.py
158
160
  src/mcp_openstack_ops/tools/set_volume_groups.py
159
- src/mcp_openstack_ops/tools/set_volume_qos.py
161
+ src/mcp_openstack_ops/tools/set_volume_qos.py
162
+ tests/test_publish_release.py
163
+ tests/test_release.py
@@ -1,5 +1,5 @@
1
1
  fastmcp>=2.12.3
2
- openstacksdk<=4.20.0,>=4.1.0
2
+ openstacksdk<=4.21.0,>=4.1.0
3
3
  python-dotenv>=1.1.1
4
4
  setuptools-scm>=10.0.5
5
5
  pre-commit>=4.6.0
@@ -29,7 +29,9 @@
29
29
  "mcp-config.json.multi-project",
30
30
  "mcp-config.json.stdio",
31
31
  "pyproject.toml",
32
+ "scripts/check_release.py",
32
33
  "scripts/mcp-server-docker-cmd.sh",
34
+ "scripts/publish_release.py",
33
35
  "scripts/run-mcp-inspector-local.sh",
34
36
  "scripts/run-mcp-inspector-pypi.sh",
35
37
  "src/mcp_openstack_ops/__init__.py",
@@ -150,6 +152,8 @@
150
152
  "src/mcp_openstack_ops/tools/set_volume_qos.py",
151
153
  "test_compatibility.sh",
152
154
  "test_project_isolation.py",
155
+ "tests/test_publish_release.py",
156
+ "tests/test_release.py",
153
157
  "uv.lock"
154
158
  ]
155
159
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "3.5.6",
3
+ "distance": 0,
4
+ "node": "g41f8358a600a2396e41e6d5b6e4326a6550ab2a0",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-24"
8
+ }
@@ -0,0 +1,153 @@
1
+ """Verify release publication without writing Git state or calling GitHub."""
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pytest
9
+ import yaml
10
+
11
+ from scripts import publish_release
12
+
13
+ REPOSITORY = "example/project"
14
+ CURRENT = "b" * 40
15
+ BASE = "a" * 40
16
+
17
+
18
+ @pytest.fixture
19
+ def commands(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
20
+ state: dict[str, Any] = {"calls": [], "created": [], "releases": [[]]}
21
+
22
+ def execute(*args: str) -> str:
23
+ state["calls"].append(args)
24
+ if args[:3] == ("gh", "release", "create"):
25
+ path = Path(args[args.index("--notes-file") + 1])
26
+ state["created"].append((args, path.read_text()))
27
+ return "https://github.com/example/project/releases/tag/0.6.6"
28
+ if args[:2] == ("gh", "api"):
29
+ if "--paginate" in args:
30
+ if state.get("api_failure"):
31
+ raise subprocess.CalledProcessError(1, args, stderr="API failed")
32
+ return json.dumps(state["releases"])
33
+ return state.get("remote", CURRENT)
34
+ if args == ("git", "rev-parse", "HEAD"):
35
+ return state.get("head", CURRENT)
36
+ if args[:3] == ("git", "rev-parse", "--verify"):
37
+ return (
38
+ CURRENT
39
+ if args[-1].endswith("0.6.6^{commit}")
40
+ or args[-1].endswith("0.6.6rc1^{commit}")
41
+ else BASE
42
+ )
43
+ if args[:3] == ("git", "merge-base", "--is-ancestor"):
44
+ if state.get("unrelated"):
45
+ raise subprocess.CalledProcessError(1, args)
46
+ return ""
47
+ if args[:2] == ("git", "log"):
48
+ return (
49
+ f"{'c' * 40}\0feat: first change\n\nBREAKING CHANGE: use start\n\0\n"
50
+ f"{CURRENT}\0fix: second change\n\n```sh\n$(touch marker)\n```\n\0"
51
+ )
52
+ raise AssertionError(args)
53
+
54
+ monkeypatch.setattr(publish_release, "command", execute)
55
+ return state
56
+
57
+
58
+ def release(tag: str, published_at: str, *, draft: bool = False) -> dict[str, Any]:
59
+ return {"tag_name": tag, "published_at": published_at, "draft": draft}
60
+
61
+
62
+ def test_uses_previous_published_release_across_pages(commands) -> None:
63
+ commands["releases"] = [
64
+ [release("0.6.5", "2026-10-24T12:00:00Z", draft=True)],
65
+ [release("0.6.4", "2026-10-23T12:00:00Z")],
66
+ [release("0.6.3", "2026-10-22T12:00:00Z")],
67
+ ]
68
+ publish_release.publish_release(REPOSITORY, "0.6.6")
69
+ args, notes = commands["created"][0]
70
+ assert "--verify-tag" in args
71
+ assert args[args.index("--title") + 1] == "0.6.6"
72
+ assert "Changes since 0.6.4." in notes
73
+ assert "0.6.4...0.6.6" in notes
74
+ assert notes.index("first change") < notes.index("second change")
75
+ assert "BREAKING CHANGE: use start" in notes
76
+ assert "$(touch marker)" in notes
77
+ assert "````text" in notes
78
+ assert ("git", "merge-base", "--is-ancestor", BASE, CURRENT) in commands["calls"]
79
+ log = next(call for call in commands["calls"] if call[:2] == ("git", "log"))
80
+ assert "--reverse" in log and "--topo-order" in log
81
+ assert f"{BASE}..{CURRENT}" in log
82
+ assert not Path(args[args.index("--notes-file") + 1]).exists()
83
+
84
+
85
+ def test_published_release_is_not_overwritten(commands) -> None:
86
+ commands["releases"] = [[release("0.6.6", "2026-10-24T12:00:00Z")]]
87
+ publish_release.publish_release(REPOSITORY, "0.6.6")
88
+ assert commands["created"] == []
89
+ assert not any(call[:2] == ("git", "log") for call in commands["calls"])
90
+
91
+
92
+ def test_existing_draft_requires_manual_review(commands) -> None:
93
+ commands["releases"] = [[release("0.6.6", "", draft=True)]]
94
+ with pytest.raises(ValueError, match="draft already exists"):
95
+ publish_release.publish_release(REPOSITORY, "0.6.6")
96
+ assert commands["created"] == []
97
+
98
+
99
+ def test_first_release_includes_all_history(commands) -> None:
100
+ publish_release.publish_release(REPOSITORY, "0.6.6")
101
+ _, notes = commands["created"][0]
102
+ assert "Initial release." in notes
103
+ assert "Full Changelog" not in notes
104
+ log = next(call for call in commands["calls"] if call[:2] == ("git", "log"))
105
+ assert log[-2:] == (CURRENT, "--")
106
+
107
+
108
+ def test_prerelease_is_not_marked_latest(commands) -> None:
109
+ publish_release.publish_release(REPOSITORY, "0.6.6rc1")
110
+ args, _ = commands["created"][0]
111
+ assert "--prerelease" in args and "--latest=false" in args
112
+
113
+
114
+ @pytest.mark.parametrize("field", ["remote", "head"])
115
+ def test_mismatched_commit_blocks_publication(commands, field) -> None:
116
+ commands[field] = "d" * 40
117
+ with pytest.raises(ValueError, match="must match"):
118
+ publish_release.publish_release(REPOSITORY, "0.6.6")
119
+ assert commands["created"] == []
120
+
121
+
122
+ @pytest.mark.parametrize("failure", ["api_failure", "unrelated"])
123
+ def test_api_or_ancestry_failure_does_not_publish(commands, failure) -> None:
124
+ commands["releases"] = [[release("0.6.4", "2026-10-23T12:00:00Z")]]
125
+ commands[failure] = True
126
+ with pytest.raises(subprocess.CalledProcessError):
127
+ publish_release.publish_release(REPOSITORY, "0.6.6")
128
+ assert commands["created"] == []
129
+
130
+
131
+ def test_invalid_tag_does_not_call_commands(commands) -> None:
132
+ with pytest.raises(ValueError):
133
+ publish_release.publish_release(REPOSITORY, "$(touch marker)")
134
+ assert commands["calls"] == []
135
+
136
+
137
+ def test_release_job_is_separate_and_uses_scoped_permissions() -> None:
138
+ root = Path(__file__).resolve().parents[1]
139
+ workflow = yaml.safe_load((root / ".github/workflows/pypi-publish.yml").read_text())
140
+ assert workflow["permissions"] == {"contents": "read"}
141
+ job = workflow["jobs"]["release"]
142
+ assert job["needs"] == "publish-to-pypi"
143
+ assert job["permissions"] == {"contents": "write"}
144
+ assert job["concurrency"]["cancel-in-progress"] is False
145
+ assert job["concurrency"]["queue"] == "max"
146
+ assert job["steps"][0]["with"]["fetch-depth"] == 0
147
+ assert job["env"]["GH_TOKEN"] == "${{ github.token }}"
148
+ assert (
149
+ job["steps"][-1]["run"] == 'python3 -m scripts.publish_release "$RELEASE_TAG"'
150
+ )
151
+ for name in ("publish-to-testpypi", "publish-to-pypi"):
152
+ assert workflow["jobs"][name]["permissions"] == {"id-token": "write"}
153
+