modwire 3.1.1__tar.gz → 4.0.0__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 (169) hide show
  1. modwire-4.0.0/.github/modwire-ecosystem.yml +169 -0
  2. modwire-4.0.0/.github/scripts/reconcile_ecosystem.py +400 -0
  3. modwire-4.0.0/.github/workflows/ci.yml +30 -0
  4. modwire-4.0.0/.github/workflows/python-github-release.yml +34 -0
  5. modwire-4.0.0/.github/workflows/python-package.yml +84 -0
  6. modwire-4.0.0/.github/workflows/python-release-build.yml +86 -0
  7. modwire-4.0.0/.github/workflows/release.yml +46 -0
  8. {modwire-3.1.1 → modwire-4.0.0}/.gitignore +1 -0
  9. modwire-4.0.0/.modwire/boundaries.yaml +44 -0
  10. modwire-4.0.0/.modwire/shape.yaml +5 -0
  11. modwire-4.0.0/CHANGELOG.md +88 -0
  12. {modwire-3.1.1 → modwire-4.0.0}/CONTRIBUTING.md +3 -1
  13. modwire-4.0.0/MANIFEST.in +1 -0
  14. modwire-4.0.0/Makefile +6 -0
  15. modwire-4.0.0/PKG-INFO +119 -0
  16. modwire-4.0.0/README.md +81 -0
  17. modwire-4.0.0/docs/governance/github-ecosystem.md +182 -0
  18. modwire-4.0.0/docs/governance/python-workflows.md +123 -0
  19. modwire-4.0.0/docs/wiki/Development-checks.md +25 -0
  20. modwire-4.0.0/docs/wiki/Home.md +27 -0
  21. {modwire-3.1.1 → modwire-4.0.0}/pyproject.toml +16 -14
  22. modwire-4.0.0/src/modwire/__init__.py +12 -0
  23. {modwire-3.1.1 → modwire-4.0.0}/src/modwire/_version.py +3 -3
  24. modwire-4.0.0/src/modwire/architecture/__init__.py +10 -0
  25. modwire-4.0.0/src/modwire/architecture/app.py +137 -0
  26. modwire-4.0.0/src/modwire/architecture/boundaries/__init__.py +8 -0
  27. modwire-4.0.0/src/modwire/architecture/boundaries/analyzer.py +56 -0
  28. modwire-4.0.0/src/modwire/architecture/boundaries/analyzers/__init__.py +1 -0
  29. modwire-4.0.0/src/modwire/architecture/boundaries/analyzers/analyzer_base.py +41 -0
  30. {modwire-3.1.1 → modwire-4.0.0}/src/modwire/architecture/boundaries/analyzers/backward.py +10 -6
  31. {modwire-3.1.1 → modwire-4.0.0}/src/modwire/architecture/boundaries/analyzers/no_cycles.py +10 -9
  32. {modwire-3.1.1 → modwire-4.0.0}/src/modwire/architecture/boundaries/analyzers/no_reentry.py +10 -9
  33. modwire-4.0.0/src/modwire/architecture/boundaries/base.py +54 -0
  34. modwire-4.0.0/src/modwire/architecture/boundaries/collector.py +32 -0
  35. modwire-4.0.0/src/modwire/architecture/boundaries/tags/__init__.py +9 -0
  36. modwire-4.0.0/src/modwire/architecture/boundaries/tags/matcher.py +135 -0
  37. {modwire-3.1.1 → modwire-4.0.0}/src/modwire/architecture/boundaries/tags/tag_map.py +6 -3
  38. modwire-4.0.0/src/modwire/architecture/insights/__init__.py +8 -0
  39. modwire-4.0.0/src/modwire/architecture/insights/base.py +14 -0
  40. modwire-4.0.0/src/modwire/architecture/insights/collector.py +31 -0
  41. modwire-4.0.0/src/modwire/architecture/insights/reporters/__init__.py +7 -0
  42. modwire-4.0.0/src/modwire/architecture/insights/reporters/base.py +43 -0
  43. modwire-4.0.0/src/modwire/architecture/insights/reporters/callables.py +60 -0
  44. {modwire-3.1.1/src/modwire/architecture/insight/reports → modwire-4.0.0/src/modwire/architecture/insights/reporters}/clusters.py +21 -19
  45. {modwire-3.1.1/src/modwire/architecture/insight/reports → modwire-4.0.0/src/modwire/architecture/insights/reporters}/coherence.py +24 -15
  46. {modwire-3.1.1/src/modwire/architecture/insight/reports → modwire-4.0.0/src/modwire/architecture/insights/reporters}/exports.py +18 -22
  47. {modwire-3.1.1/src/modwire/architecture/insight/reports → modwire-4.0.0/src/modwire/architecture/insights/reporters}/hotspots.py +25 -20
  48. modwire-4.0.0/src/modwire/architecture/map/__init__.py +11 -0
  49. modwire-4.0.0/src/modwire/architecture/map/base.py +20 -0
  50. modwire-4.0.0/src/modwire/architecture/map/collector.py +40 -0
  51. modwire-4.0.0/src/modwire/architecture/map/loader.py +63 -0
  52. modwire-4.0.0/src/modwire/architecture/shape/__init__.py +8 -0
  53. modwire-4.0.0/src/modwire/architecture/shape/base.py +92 -0
  54. modwire-4.0.0/src/modwire/architecture/shape/collector.py +42 -0
  55. {modwire-3.1.1/src/modwire/architecture/shape/rules → modwire-4.0.0/src/modwire/architecture/shape/resolvers}/abstract_class_resolver.py +22 -18
  56. {modwire-3.1.1/src/modwire/architecture/shape/rules → modwire-4.0.0/src/modwire/architecture/shape/resolvers}/callable_resolver.py +31 -31
  57. {modwire-3.1.1/src/modwire/architecture/shape/rules → modwire-4.0.0/src/modwire/architecture/shape/resolvers}/class_resolver.py +25 -22
  58. modwire-4.0.0/src/modwire/architecture/shape/resolvers/file_resolver.py +77 -0
  59. {modwire-3.1.1/src/modwire/architecture/shape/rules → modwire-4.0.0/src/modwire/architecture/shape/resolvers}/import_resolver.py +19 -12
  60. modwire-4.0.0/src/modwire/architecture/shape/resolvers/property_resolver.py +92 -0
  61. {modwire-3.1.1/src/modwire/architecture/shape/rules → modwire-4.0.0/src/modwire/architecture/shape/resolvers}/signature_resolver.py +28 -24
  62. modwire-4.0.0/src/modwire/architecture/shape/resolvers/symbol_resolver.py +33 -0
  63. modwire-4.0.0/src/modwire/code/__init__.py +10 -0
  64. modwire-4.0.0/src/modwire/code/package.py +48 -0
  65. modwire-4.0.0/src/modwire/code/reader.py +9 -0
  66. modwire-4.0.0/src/modwire/code/writer.py +27 -0
  67. modwire-4.0.0/src/modwire/facade.py +30 -0
  68. modwire-4.0.0/src/modwire/layers/__init__.py +11 -0
  69. modwire-4.0.0/src/modwire/layers/app.py +9 -0
  70. modwire-4.0.0/src/modwire/modules/__init__.py +4 -0
  71. modwire-4.0.0/src/modwire/modules/app.py +9 -0
  72. modwire-4.0.0/src/modwire/projects/__init__.py +43 -0
  73. modwire-4.0.0/src/modwire/projects/app.py +9 -0
  74. modwire-4.0.0/src/modwire/projects/ecosystem.py +267 -0
  75. modwire-4.0.0/src/modwire/py.typed +1 -0
  76. modwire-4.0.0/src/modwire/shared/__init__.py +19 -0
  77. modwire-4.0.0/src/modwire/shared/base.py +76 -0
  78. modwire-4.0.0/src/modwire/shared/config/__init__.py +36 -0
  79. modwire-4.0.0/src/modwire/shared/config/architecture.py +67 -0
  80. modwire-4.0.0/src/modwire/shared/config/layers.py +30 -0
  81. modwire-4.0.0/src/modwire/shared/config/modules.py +18 -0
  82. modwire-4.0.0/src/modwire/shared/config/modwire.py +54 -0
  83. modwire-4.0.0/src/modwire/shared/config/projects.py +27 -0
  84. modwire-3.1.1/src/modwire/architecture/shape/config.py → modwire-4.0.0/src/modwire/shared/config/shape.py +8 -13
  85. modwire-4.0.0/src/modwire/shared/report/__init__.py +18 -0
  86. modwire-4.0.0/src/modwire/shared/report/base.py +105 -0
  87. modwire-4.0.0/src/modwire.egg-info/PKG-INFO +119 -0
  88. modwire-4.0.0/src/modwire.egg-info/SOURCES.txt +104 -0
  89. modwire-4.0.0/src/modwire.egg-info/requires.txt +11 -0
  90. modwire-4.0.0/src/modwire.egg-info/scm_file_list.json +101 -0
  91. modwire-4.0.0/src/modwire.egg-info/scm_version.json +8 -0
  92. modwire-4.0.0/tests/test_architecture.py +19 -0
  93. modwire-4.0.0/tests/test_ecosystem_contract.py +68 -0
  94. modwire-4.0.0/tests/test_models.py +60 -0
  95. {modwire-3.1.1 → modwire-4.0.0}/uv.lock +60 -291
  96. modwire-3.1.1/.github/workflows/ci.yml +0 -45
  97. modwire-3.1.1/.github/workflows/release.yml +0 -210
  98. modwire-3.1.1/CHANGELOG.md +0 -31
  99. modwire-3.1.1/PKG-INFO +0 -198
  100. modwire-3.1.1/README.md +0 -163
  101. modwire-3.1.1/docs/wiki/Development-checks.md +0 -18
  102. modwire-3.1.1/docs/wiki/Home.md +0 -25
  103. modwire-3.1.1/src/modwire/__init__.py +0 -6
  104. modwire-3.1.1/src/modwire/architecture/__init__.py +0 -26
  105. modwire-3.1.1/src/modwire/architecture/boundaries/analyzers/__init__.py +0 -0
  106. modwire-3.1.1/src/modwire/architecture/boundaries/base.py +0 -93
  107. modwire-3.1.1/src/modwire/architecture/boundaries/config.py +0 -43
  108. modwire-3.1.1/src/modwire/architecture/boundaries/map.py +0 -155
  109. modwire-3.1.1/src/modwire/architecture/boundaries/pipeline.py +0 -108
  110. modwire-3.1.1/src/modwire/architecture/boundaries/tags/__init__.py +0 -0
  111. modwire-3.1.1/src/modwire/architecture/boundaries/tags/matcher.py +0 -131
  112. modwire-3.1.1/src/modwire/architecture/config.py +0 -14
  113. modwire-3.1.1/src/modwire/architecture/insight/__init__.py +0 -0
  114. modwire-3.1.1/src/modwire/architecture/insight/base.py +0 -14
  115. modwire-3.1.1/src/modwire/architecture/insight/pipeline.py +0 -92
  116. modwire-3.1.1/src/modwire/architecture/insight/reports/__init__.py +0 -22
  117. modwire-3.1.1/src/modwire/architecture/insight/reports/callables.py +0 -54
  118. modwire-3.1.1/src/modwire/architecture/report.py +0 -102
  119. modwire-3.1.1/src/modwire/architecture/root.py +0 -18
  120. modwire-3.1.1/src/modwire/architecture/shape/__init__.py +0 -0
  121. modwire-3.1.1/src/modwire/architecture/shape/base.py +0 -52
  122. modwire-3.1.1/src/modwire/architecture/shape/pipeline.py +0 -95
  123. modwire-3.1.1/src/modwire/architecture/shape/rules/__init__.py +0 -20
  124. modwire-3.1.1/src/modwire/architecture/shape/rules/file_resolver.py +0 -57
  125. modwire-3.1.1/src/modwire/architecture/shape/rules/property_resolver.py +0 -81
  126. modwire-3.1.1/src/modwire/architecture/shape/rules/symbol_resolver.py +0 -34
  127. modwire-3.1.1/src/modwire/modules/__init__.py +0 -3
  128. modwire-3.1.1/src/modwire/modules/generate.py +0 -74
  129. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/copier.yml +0 -38
  130. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/php/src/Application/{{ command_name }}.php.jinja +0 -14
  131. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/php/src/Application/{{ service_name }}.php.jinja +0 -24
  132. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/php/src/Domain/{{ aggregate_name }}.php.jinja +0 -27
  133. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/php/src/Infrastructure/InMemory{{ repository_name }}.php.jinja +0 -24
  134. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/php/src/Ports/{{ repository_name }}.php.jinja +0 -14
  135. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/php/src/Ui/{{ controller_name }}.php.jinja +0 -27
  136. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/python/{{ module_name }}/__init__.py.jinja +0 -5
  137. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/python/{{ module_name }}/application.py.jinja +0 -22
  138. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/python/{{ module_name }}/domain.py.jinja +0 -20
  139. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/python/{{ module_name }}/infrastructure.py.jinja +0 -17
  140. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/python/{{ module_name }}/ports.py.jinja +0 -16
  141. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/python/{{ module_name }}/ui.py.jinja +0 -21
  142. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/typescript/{{ module_name }}/application.ts.jinja +0 -20
  143. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/typescript/{{ module_name }}/domain.ts.jinja +0 -18
  144. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/typescript/{{ module_name }}/infrastructure.ts.jinja +0 -14
  145. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/typescript/{{ module_name }}/ports.ts.jinja +0 -6
  146. modwire-3.1.1/src/modwire/modules/scaffoldings/hexagonal/template/typescript/{{ module_name }}/ui.ts.jinja +0 -19
  147. modwire-3.1.1/src/modwire/modules/scaffoldings/layered/copier.yml +0 -23
  148. modwire-3.1.1/src/modwire/modules/scaffoldings/layered/template/{{ module_name }}/application.py.jinja +0 -18
  149. modwire-3.1.1/src/modwire/modules/scaffoldings/layered/template/{{ module_name }}/domain.py.jinja +0 -12
  150. modwire-3.1.1/src/modwire/modules/scaffoldings/layered/template/{{ module_name }}/infrastructure.py.jinja +0 -11
  151. modwire-3.1.1/src/modwire/modules/scaffoldings/layered/template/{{ module_name }}/ui.py.jinja +0 -20
  152. modwire-3.1.1/src/modwire.egg-info/PKG-INFO +0 -198
  153. modwire-3.1.1/src/modwire.egg-info/SOURCES.txt +0 -91
  154. modwire-3.1.1/src/modwire.egg-info/requires.txt +0 -9
  155. modwire-3.1.1/tests/architecture/boundaries/test_map.py +0 -69
  156. modwire-3.1.1/tests/architecture/boundaries/test_pipeline.py +0 -122
  157. modwire-3.1.1/tests/architecture/test_report.py +0 -110
  158. modwire-3.1.1/tests/modules/test_generate.py +0 -56
  159. {modwire-3.1.1 → modwire-4.0.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  160. {modwire-3.1.1 → modwire-4.0.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  161. {modwire-3.1.1 → modwire-4.0.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  162. {modwire-3.1.1 → modwire-4.0.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  163. {modwire-3.1.1 → modwire-4.0.0}/LICENSE +0 -0
  164. {modwire-3.1.1 → modwire-4.0.0}/docs/wiki/Reporting-bugs.md +0 -0
  165. {modwire-3.1.1 → modwire-4.0.0}/docs/wiki/Requesting-features.md +0 -0
  166. {modwire-3.1.1 → modwire-4.0.0}/setup.cfg +0 -0
  167. {modwire-3.1.1/src/modwire/architecture/boundaries → modwire-4.0.0/src/modwire/architecture/shape/resolvers}/__init__.py +0 -0
  168. {modwire-3.1.1 → modwire-4.0.0}/src/modwire.egg-info/dependency_links.txt +0 -0
  169. {modwire-3.1.1 → modwire-4.0.0}/src/modwire.egg-info/top_level.txt +0 -0
@@ -0,0 +1,169 @@
1
+ version: 2
2
+
3
+ packages:
4
+ core:
5
+ repository: 9orky/modwire
6
+ distribution: modwire
7
+ component: Core
8
+ role: coordinator
9
+ lifecycle: active
10
+ summary: Typed coordination library and ecosystem integration gate.
11
+ depends_on: [extraction, mermaid, siren]
12
+ cli:
13
+ repository: 9orky/modwire-cli
14
+ distribution: modwire-cli
15
+ component: CLI
16
+ role: runtime
17
+ lifecycle: active
18
+ summary: Command-line runtime and user-facing workflows.
19
+ depends_on: [core]
20
+ extraction:
21
+ repository: 9orky/modwire-extraction
22
+ distribution: modwire-extraction
23
+ component: Extraction
24
+ role: building_block
25
+ lifecycle: active
26
+ summary: Canonical source extraction and normalization.
27
+ mermaid:
28
+ repository: 9orky/modwire-mermaid
29
+ distribution: modwire-mermaid
30
+ component: Mermaid
31
+ role: building_block
32
+ lifecycle: active
33
+ summary: Typed, deterministic Mermaid source generation.
34
+ siren:
35
+ repository: 9orky/modwire-siren
36
+ distribution: modwire-siren
37
+ component: Siren
38
+ role: building_block
39
+ lifecycle: active
40
+ summary: Typed Siren hypermedia and OpenAPI integration.
41
+ mcp:
42
+ repository: 9orky/modwire-mcp
43
+ distribution: modwire-mcp
44
+ component: MCP
45
+ role: integration
46
+ lifecycle: incubating
47
+ summary: MCP surface and future home of scaffolding operations.
48
+ depends_on: [core]
49
+
50
+ project:
51
+ owner: 9orky
52
+ title: Modwire Ecosystem
53
+ number: 4
54
+ visibility: public
55
+ default_package: core
56
+ description: Cross-repository roadmap and release coordination for the Modwire ecosystem.
57
+ governance_url: https://github.com/9orky/modwire/blob/main/docs/governance/github-ecosystem.md
58
+
59
+ workflows:
60
+ contract_ref: workflows-v1
61
+ ci_entrypoint: .github/workflows/ci.yml
62
+ release_entrypoint: .github/workflows/release.yml
63
+ verify_reusable: .github/workflows/python-package.yml
64
+ release_build_reusable: .github/workflows/python-release-build.yml
65
+ github_release_reusable: .github/workflows/python-github-release.yml
66
+ release_tag_pattern: ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$
67
+ artifact_name: python-package-distributions
68
+ pypi_environment: pypi
69
+ actions:
70
+ checkout: actions/checkout@v6
71
+ setup_python: actions/setup-python@v6
72
+ upload_artifact: actions/upload-artifact@v7
73
+ download_artifact: actions/download-artifact@v8
74
+ publish_pypi: pypa/gh-action-pypi-publish@release/v1
75
+
76
+ fields:
77
+ Status:
78
+ type: single_select
79
+ options: [Inbox, Ready, In progress, Blocked, Review, Done]
80
+ Horizon:
81
+ type: single_select
82
+ options: [Now, Next, Later]
83
+ Priority:
84
+ type: single_select
85
+ options: [P0, P1, P2, P3]
86
+ Component:
87
+ type: single_select
88
+ source: packages
89
+ Release train:
90
+ type: text
91
+ Target date:
92
+ type: date
93
+ Risk:
94
+ type: single_select
95
+ options: [Low, Medium, High]
96
+
97
+ views:
98
+ - name: Roadmap
99
+ layout: roadmap
100
+ group_by: Release train
101
+ slice_by: Component
102
+ - name: Now
103
+ layout: board
104
+ filter: "Horizon:Now -Status:Done"
105
+ group_by: Status
106
+ - name: By package
107
+ layout: table
108
+ filter: "-Status:Done"
109
+ group_by: Repository
110
+ - name: Blocked
111
+ layout: table
112
+ filter: "Status:Blocked"
113
+ group_by: Component
114
+ - name: Release readiness
115
+ layout: table
116
+ filter: "has:\"Release train\" -Status:Done"
117
+ group_by: Release train
118
+ - name: Inbox
119
+ layout: table
120
+ filter: "Status:Inbox"
121
+
122
+ labels:
123
+ "type:bug":
124
+ color: "d73a4a"
125
+ description: Incorrect existing behavior
126
+ "type:feature":
127
+ color: "0e8a16"
128
+ description: New user-facing capability
129
+ "type:maintenance":
130
+ color: "c5def5"
131
+ description: Internal maintenance
132
+ "type:docs":
133
+ color: "0075ca"
134
+ description: Documentation
135
+ ecosystem:
136
+ color: "5319e7"
137
+ description: Cross-package coordination
138
+ contract:
139
+ color: "1d76db"
140
+ description: Public contract or compatibility work
141
+ breaking:
142
+ color: "b60205"
143
+ description: Backward-incompatible change
144
+ blocked:
145
+ color: "e99695"
146
+ description: Blocked by another decision or delivery
147
+ release-gate:
148
+ color: "fbca04"
149
+ description: Must complete before a release
150
+
151
+ automation:
152
+ auto_add_filter: "is:open"
153
+ added_status: Inbox
154
+ closed_status: Done
155
+ merged_status: Done
156
+ archive_filter: "Status:Done updated:<@today-30d"
157
+
158
+ milestones:
159
+ scope: repository
160
+ naming: "<version-or-phase>: <outcome>"
161
+ close_when_complete: true
162
+ ecosystem_coordination_field: Release train
163
+ cross_repository_work: parent_issue_in_modwire_with_sub_issues
164
+
165
+ cadence:
166
+ triage: weekly
167
+ roadmap_review: monthly
168
+ release_readiness: before_each_package_release
169
+ project_status_update: monthly
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from modwire.projects import EcosystemContract, EcosystemDrift, ProjectFieldType
12
+
13
+
14
+ ROOT = Path(__file__).resolve().parents[2]
15
+ DEFAULT_CONTRACT = ROOT / ".github" / "modwire-ecosystem.yml"
16
+
17
+
18
+ def gh(*arguments: str) -> str:
19
+ result = subprocess.run(
20
+ ["gh", *arguments],
21
+ check=True,
22
+ capture_output=True,
23
+ text=True,
24
+ )
25
+ return result.stdout
26
+
27
+
28
+ def gh_json(*arguments: str) -> Any:
29
+ return json.loads(gh(*arguments))
30
+
31
+
32
+ def load_contract(path: Path) -> EcosystemContract:
33
+ return EcosystemContract.load_yaml(path)
34
+
35
+
36
+ def workflow_drift(contract: EcosystemContract) -> list[str]:
37
+ errors: list[str] = []
38
+ workflow_paths = (
39
+ contract.workflows.ci_entrypoint,
40
+ contract.workflows.release_entrypoint,
41
+ contract.workflows.verify_reusable,
42
+ contract.workflows.release_build_reusable,
43
+ contract.workflows.github_release_reusable,
44
+ )
45
+ contents: dict[str, str] = {}
46
+ for relative_path in workflow_paths:
47
+ path = ROOT / relative_path
48
+ if not path.is_file():
49
+ errors.append(f"missing workflow file {relative_path}")
50
+ continue
51
+ contents[relative_path] = path.read_text(encoding="utf-8")
52
+
53
+ expected_actions = {
54
+ "actions/checkout": contract.workflows.actions.checkout,
55
+ "actions/setup-python": contract.workflows.actions.setup_python,
56
+ "actions/upload-artifact": contract.workflows.actions.upload_artifact,
57
+ "actions/download-artifact": contract.workflows.actions.download_artifact,
58
+ "pypa/gh-action-pypi-publish": contract.workflows.actions.publish_pypi,
59
+ }
60
+ for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")):
61
+ text = path.read_text(encoding="utf-8")
62
+ for action in re.findall(
63
+ r"(?:actions/(?:checkout|setup-python|upload-artifact|download-artifact)|"
64
+ r"pypa/gh-action-pypi-publish)@[^\s]+",
65
+ text,
66
+ ):
67
+ name = action.split("@", maxsplit=1)[0]
68
+ if action != expected_actions[name]:
69
+ errors.append(f"{path.name}: unmanaged action reference {action}")
70
+
71
+ release = contents.get(contract.workflows.release_entrypoint, "")
72
+ release_build = contents.get(contract.workflows.release_build_reusable, "")
73
+ verify = contents.get(contract.workflows.verify_reusable, "")
74
+ required_references = {
75
+ contract.workflows.release_entrypoint: (
76
+ contract.workflows.release_build_reusable,
77
+ contract.workflows.github_release_reusable,
78
+ contract.workflows.actions.download_artifact,
79
+ contract.workflows.actions.publish_pypi,
80
+ ),
81
+ contract.workflows.release_build_reusable: (
82
+ contract.workflows.actions.checkout,
83
+ contract.workflows.actions.setup_python,
84
+ contract.workflows.actions.upload_artifact,
85
+ ),
86
+ contract.workflows.verify_reusable: (
87
+ contract.workflows.actions.checkout,
88
+ contract.workflows.actions.setup_python,
89
+ contract.workflows.actions.upload_artifact,
90
+ ),
91
+ contract.workflows.github_release_reusable: (
92
+ contract.workflows.actions.download_artifact,
93
+ ),
94
+ }
95
+ for path, references in required_references.items():
96
+ text = contents.get(path, "")
97
+ for reference in references:
98
+ if reference not in text:
99
+ errors.append(f"{path}: missing required reference {reference}")
100
+
101
+ for forbidden in (
102
+ "python - <<",
103
+ "SETUPTOOLS_SCM_PRETEND_VERSION",
104
+ "softprops/",
105
+ "types: [published]",
106
+ ):
107
+ if forbidden in release or forbidden in release_build:
108
+ errors.append(f"release workflows contain forbidden pattern {forbidden}")
109
+ if contract.workflows.artifact_name not in release or contract.workflows.artifact_name not in release_build:
110
+ errors.append("release artifact name differs from the workflow contract")
111
+ github_release = contents.get(contract.workflows.github_release_reusable, "")
112
+ if (
113
+ "workflow_call:" not in verify
114
+ or "workflow_call:" not in release_build
115
+ or "workflow_call:" not in github_release
116
+ ):
117
+ errors.append("reusable workflows must use workflow_call")
118
+ return errors
119
+
120
+
121
+ def project_fields(contract: EcosystemContract) -> dict[str, dict[str, Any]]:
122
+ query = """
123
+ query($id: ID!) {
124
+ node(id: $id) {
125
+ ... on ProjectV2 {
126
+ fields(first: 100) {
127
+ nodes {
128
+ __typename
129
+ ... on ProjectV2Field { id name dataType }
130
+ ... on ProjectV2SingleSelectField { id name options { name } }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ """
137
+ project = gh_json(
138
+ "project",
139
+ "view",
140
+ str(contract.project.number),
141
+ "--owner",
142
+ contract.project.owner,
143
+ "--format",
144
+ "json",
145
+ )
146
+ data = gh_json(
147
+ "api",
148
+ "graphql",
149
+ "-F",
150
+ f"id={project['id']}",
151
+ "-f",
152
+ f"query={query}",
153
+ )
154
+ nodes = data["data"]["node"]["fields"]["nodes"]
155
+ return {node["name"]: node for node in nodes if node.get("name")}
156
+
157
+
158
+ def linked_repositories(contract: EcosystemContract) -> set[str]:
159
+ query = """
160
+ query($id: ID!) {
161
+ node(id: $id) {
162
+ ... on ProjectV2 { repositories(first: 100) { nodes { nameWithOwner } } }
163
+ }
164
+ }
165
+ """
166
+ project = gh_json(
167
+ "project",
168
+ "view",
169
+ str(contract.project.number),
170
+ "--owner",
171
+ contract.project.owner,
172
+ "--format",
173
+ "json",
174
+ )
175
+ data = gh_json(
176
+ "api",
177
+ "graphql",
178
+ "-F",
179
+ f"id={project['id']}",
180
+ "-f",
181
+ f"query={query}",
182
+ )
183
+ nodes = data["data"]["node"]["repositories"]["nodes"]
184
+ return {node["nameWithOwner"] for node in nodes}
185
+
186
+
187
+ def open_work_urls(contract: EcosystemContract) -> set[str]:
188
+ urls: set[str] = set()
189
+ for repository in contract.repositories():
190
+ for kind in ("issue", "pr"):
191
+ items = gh_json(
192
+ kind,
193
+ "list",
194
+ "--repo",
195
+ repository,
196
+ "--state",
197
+ "open",
198
+ "--limit",
199
+ "100",
200
+ "--json",
201
+ "url",
202
+ )
203
+ urls.update(item["url"] for item in items)
204
+ return urls
205
+
206
+
207
+ def project_item_urls(contract: EcosystemContract) -> set[str]:
208
+ data = gh_json(
209
+ "project",
210
+ "item-list",
211
+ str(contract.project.number),
212
+ "--owner",
213
+ contract.project.owner,
214
+ "--limit",
215
+ "1000",
216
+ "--format",
217
+ "json",
218
+ )
219
+ return {
220
+ item["content"]["url"]
221
+ for item in data["items"]
222
+ if item.get("content", {}).get("url")
223
+ }
224
+
225
+
226
+ def label_drift(contract: EcosystemContract) -> list[str]:
227
+ errors: list[str] = []
228
+ for repository in contract.repositories():
229
+ actual = {
230
+ label["name"]: label
231
+ for label in gh_json(
232
+ "label",
233
+ "list",
234
+ "--repo",
235
+ repository,
236
+ "--limit",
237
+ "100",
238
+ "--json",
239
+ "name,color,description",
240
+ )
241
+ }
242
+ for name, expected in contract.labels.items():
243
+ label = actual.get(name)
244
+ if label is None:
245
+ errors.append(f"{repository}: missing label {name}")
246
+ continue
247
+ if label["color"].lower() != expected.color:
248
+ errors.append(f"{repository}: label {name} has the wrong color")
249
+ if label["description"] != expected.description:
250
+ errors.append(f"{repository}: label {name} has the wrong description")
251
+ return errors
252
+
253
+
254
+ def inspect_live(contract: EcosystemContract) -> EcosystemDrift:
255
+ errors = workflow_drift(contract)
256
+ project = gh_json(
257
+ "project",
258
+ "view",
259
+ str(contract.project.number),
260
+ "--owner",
261
+ contract.project.owner,
262
+ "--format",
263
+ "json",
264
+ )
265
+ expected_project = {
266
+ "title": contract.project.title,
267
+ "public": contract.project.visibility.value == "public",
268
+ "shortDescription": contract.project.description,
269
+ "readme": contract.project_readme(),
270
+ }
271
+ for name, expected in expected_project.items():
272
+ if project[name] != expected:
273
+ errors.append(f"project {name} differs from the contract")
274
+
275
+ expected_repositories = set(contract.repositories())
276
+ actual_repositories = linked_repositories(contract)
277
+ for repository in sorted(expected_repositories - actual_repositories):
278
+ errors.append(f"project is not linked to {repository}")
279
+ for repository in sorted(actual_repositories - expected_repositories):
280
+ errors.append(f"project has unmanaged repository {repository}")
281
+
282
+ actual_fields = project_fields(contract)
283
+ for name, expected in contract.fields.items():
284
+ actual = actual_fields.get(name)
285
+ if actual is None:
286
+ errors.append(f"project is missing field {name}")
287
+ continue
288
+ if expected.type is ProjectFieldType.SINGLE_SELECT:
289
+ options = tuple(option["name"] for option in actual.get("options", ()))
290
+ if options != contract.field_options(name):
291
+ errors.append(f"project field {name} options differ from the contract")
292
+ elif actual.get("dataType", "").lower() != expected.type.value:
293
+ errors.append(f"project field {name} type differs from the contract")
294
+
295
+ missing_items = open_work_urls(contract) - project_item_urls(contract)
296
+ for url in sorted(missing_items):
297
+ errors.append(f"project is missing open work item {url}")
298
+ errors.extend(label_drift(contract))
299
+ return EcosystemDrift(
300
+ errors=tuple(errors),
301
+ manual_controls=(
302
+ "Saved Project views must be compared in the GitHub UI.",
303
+ "Project auto-add, close, merge, and archive workflows must be compared in the GitHub UI.",
304
+ ),
305
+ )
306
+
307
+
308
+ def apply_live(contract: EcosystemContract) -> None:
309
+ gh(
310
+ "project",
311
+ "edit",
312
+ str(contract.project.number),
313
+ "--owner",
314
+ contract.project.owner,
315
+ "--title",
316
+ contract.project.title,
317
+ "--visibility",
318
+ contract.project.visibility.value.upper(),
319
+ "--description",
320
+ contract.project.description,
321
+ "--readme",
322
+ contract.project_readme(),
323
+ )
324
+ linked = linked_repositories(contract)
325
+ for repository in contract.repositories():
326
+ if repository not in linked:
327
+ gh(
328
+ "project",
329
+ "link",
330
+ str(contract.project.number),
331
+ "--owner",
332
+ contract.project.owner,
333
+ "--repo",
334
+ repository,
335
+ )
336
+ for name, label in contract.labels.items():
337
+ gh(
338
+ "label",
339
+ "create",
340
+ name,
341
+ "--repo",
342
+ repository,
343
+ "--color",
344
+ label.color,
345
+ "--description",
346
+ label.description,
347
+ "--force",
348
+ )
349
+
350
+ existing_items = project_item_urls(contract)
351
+ for url in sorted(open_work_urls(contract) - existing_items):
352
+ gh(
353
+ "project",
354
+ "item-add",
355
+ str(contract.project.number),
356
+ "--owner",
357
+ contract.project.owner,
358
+ "--url",
359
+ url,
360
+ )
361
+
362
+
363
+ def parser() -> argparse.ArgumentParser:
364
+ result = argparse.ArgumentParser(description="Validate and reconcile Modwire governance")
365
+ result.add_argument(
366
+ "command",
367
+ choices=("validate", "check-live", "apply-live", "schema"),
368
+ nargs="?",
369
+ default="validate",
370
+ )
371
+ result.add_argument("--contract", type=Path, default=DEFAULT_CONTRACT)
372
+ return result
373
+
374
+
375
+ def main() -> int:
376
+ arguments = parser().parse_args()
377
+ contract = load_contract(arguments.contract)
378
+ if arguments.command == "schema":
379
+ print(json.dumps(EcosystemContract.model_json_schema(), indent=2))
380
+ return 0
381
+ if arguments.command == "validate":
382
+ errors = workflow_drift(contract)
383
+ if errors:
384
+ print(EcosystemDrift(errors=tuple(errors)).pretty())
385
+ return 1
386
+ print(
387
+ f"valid ecosystem contract: {len(contract.packages)} packages, "
388
+ f"{len(contract.fields)} project fields"
389
+ )
390
+ return 0
391
+ if arguments.command == "apply-live":
392
+ apply_live(contract)
393
+
394
+ drift = inspect_live(contract)
395
+ print(drift.pretty())
396
+ return 0 if drift.is_clean() else 1
397
+
398
+
399
+ if __name__ == "__main__":
400
+ raise SystemExit(main())
@@ -0,0 +1,30 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ ecosystem-contract:
15
+ name: Ecosystem contract
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - name: Check out repository
19
+ uses: actions/checkout@v6
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v6
22
+ with:
23
+ python-version: "3.12"
24
+ - name: Install coordinator
25
+ run: python -m pip install -e .
26
+ - name: Validate ecosystem contract
27
+ run: python .github/scripts/reconcile_ecosystem.py validate
28
+
29
+ verify:
30
+ uses: ./.github/workflows/python-package.yml
@@ -0,0 +1,34 @@
1
+ name: Reusable Python GitHub release
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ release-tag:
7
+ description: Existing release tag used to create the GitHub Release.
8
+ required: true
9
+ type: string
10
+ artifact-name:
11
+ description: Name of the verified distribution artifact.
12
+ required: false
13
+ type: string
14
+ default: python-package-distributions
15
+
16
+ permissions:
17
+ contents: write
18
+
19
+ jobs:
20
+ publish:
21
+ name: Create GitHub release
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - name: Download release distributions
25
+ uses: actions/download-artifact@v8
26
+ with:
27
+ name: ${{ inputs.artifact-name }}
28
+ path: dist/
29
+
30
+ - name: Create GitHub release
31
+ env:
32
+ GH_TOKEN: ${{ github.token }}
33
+ RELEASE_TAG: ${{ inputs.release-tag }}
34
+ run: gh release create "$RELEASE_TAG" dist/* --verify-tag --generate-notes