unaltraweb 0.3.0

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 (444) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/Makefile +412 -0
  4. data/README.md +226 -0
  5. data/_config.yml +842 -0
  6. data/_data/i18n/ca.yml +278 -0
  7. data/_data/i18n/en.yml +278 -0
  8. data/_data/i18n/es.yml +278 -0
  9. data/_includes/audio.liquid +30 -0
  10. data/_includes/bib_search.liquid +5 -0
  11. data/_includes/blog-list.liquid +66 -0
  12. data/_includes/book-citation.liquid +33 -0
  13. data/_includes/citation.liquid +26 -0
  14. data/_includes/custom_head.liquid +3 -0
  15. data/_includes/custom_scripts.liquid +5 -0
  16. data/_includes/cv/list.liquid +5 -0
  17. data/_includes/cv/list_groups.liquid +51 -0
  18. data/_includes/cv/map.liquid +29 -0
  19. data/_includes/cv/nested_list.liquid +16 -0
  20. data/_includes/cv/time_table.liquid +118 -0
  21. data/_includes/cv-download-card.liquid +26 -0
  22. data/_includes/developer-mode-switcher.liquid +24 -0
  23. data/_includes/disqus.liquid +13 -0
  24. data/_includes/distill_scripts.liquid +298 -0
  25. data/_includes/documentation-download-detail.liquid +76 -0
  26. data/_includes/documentation-downloads.liquid +71 -0
  27. data/_includes/documentation-profile-attributes.liquid +4 -0
  28. data/_includes/documentation-section-toc.liquid +9 -0
  29. data/_includes/documentation-sidebar.liquid +112 -0
  30. data/_includes/figure.liquid +87 -0
  31. data/_includes/footer.liquid +66 -0
  32. data/_includes/giscus.liquid +25 -0
  33. data/_includes/head.liquid +220 -0
  34. data/_includes/header.liquid +544 -0
  35. data/_includes/latest_posts.liquid +61 -0
  36. data/_includes/localized-date.liquid +5 -0
  37. data/_includes/manual-bibliography.liquid +2 -0
  38. data/_includes/manual-featured-readings.liquid +12 -0
  39. data/_includes/manual-other-readings.liquid +12 -0
  40. data/_includes/manual-page-toc.liquid +11 -0
  41. data/_includes/manual-sidebar.liquid +58 -0
  42. data/_includes/manual-teachers.liquid +74 -0
  43. data/_includes/metadata.liquid +278 -0
  44. data/_includes/news.liquid +48 -0
  45. data/_includes/newsletter.liquid +68 -0
  46. data/_includes/page-hero.liquid +96 -0
  47. data/_includes/page-link.liquid +8 -0
  48. data/_includes/pagination.liquid +22 -0
  49. data/_includes/post-reading-time.liquid +29 -0
  50. data/_includes/previous-next.liquid +37 -0
  51. data/_includes/profile-card.liquid +49 -0
  52. data/_includes/profile-feature-matrix.liquid +75 -0
  53. data/_includes/profile-highlights.liquid +122 -0
  54. data/_includes/project-grid.liquid +26 -0
  55. data/_includes/project-resource-link.liquid +45 -0
  56. data/_includes/project-resources.liquid +21 -0
  57. data/_includes/projects.liquid +38 -0
  58. data/_includes/projects_horizontal.liquid +37 -0
  59. data/_includes/publications-metrics-summary.liquid +69 -0
  60. data/_includes/publications-summary.liquid +127 -0
  61. data/_includes/reading-biblio-controls.liquid +65 -0
  62. data/_includes/reading-cover-card.liquid +93 -0
  63. data/_includes/reading-rating.liquid +11 -0
  64. data/_includes/recommended-readings.liquid +30 -0
  65. data/_includes/related_posts.liquid +39 -0
  66. data/_includes/repository/repo.liquid +48 -0
  67. data/_includes/repository/repo_trophies.liquid +44 -0
  68. data/_includes/repository/repo_user.liquid +35 -0
  69. data/_includes/resume/awards.liquid +19 -0
  70. data/_includes/resume/basics.liquid +28 -0
  71. data/_includes/resume/certificates.liquid +35 -0
  72. data/_includes/resume/education.liquid +54 -0
  73. data/_includes/resume/interests.liquid +34 -0
  74. data/_includes/resume/languages.liquid +31 -0
  75. data/_includes/resume/projects.liquid +32 -0
  76. data/_includes/resume/publications.liquid +28 -0
  77. data/_includes/resume/references.liquid +31 -0
  78. data/_includes/resume/skills.liquid +33 -0
  79. data/_includes/resume/volunteer.liquid +52 -0
  80. data/_includes/resume/work.liquid +52 -0
  81. data/_includes/scripts.liquid +364 -0
  82. data/_includes/search-data.js.liquid +341 -0
  83. data/_includes/selected_papers.liquid +3 -0
  84. data/_includes/t.liquid +31 -0
  85. data/_includes/team-grid.liquid +172 -0
  86. data/_includes/video.liquid +97 -0
  87. data/_layouts/about.liquid +93 -0
  88. data/_layouts/archive.liquid +45 -0
  89. data/_layouts/bib.liquid +677 -0
  90. data/_layouts/book-review.liquid +225 -0
  91. data/_layouts/book-shelf.liquid +66 -0
  92. data/_layouts/cv.liquid +127 -0
  93. data/_layouts/default.liquid +70 -0
  94. data/_layouts/distill.liquid +132 -0
  95. data/_layouts/documentation-download-detail.liquid +5 -0
  96. data/_layouts/documentation-home.liquid +100 -0
  97. data/_layouts/documentation-page.liquid +45 -0
  98. data/_layouts/manual-bib.liquid +73 -0
  99. data/_layouts/manual-chapter.liquid +99 -0
  100. data/_layouts/manual-featured-bib.liquid +100 -0
  101. data/_layouts/manual-home.liquid +120 -0
  102. data/_layouts/none.liquid +1 -0
  103. data/_layouts/outputs.liquid +29 -0
  104. data/_layouts/page.liquid +52 -0
  105. data/_layouts/post.liquid +95 -0
  106. data/_layouts/profile.liquid +36 -0
  107. data/_layouts/profiles.liquid +36 -0
  108. data/_layouts/project.liquid +7 -0
  109. data/_layouts/repositories.liquid +58 -0
  110. data/_layouts/theses.liquid +126 -0
  111. data/_plugins/bibliography_profiles.rb +164 -0
  112. data/_plugins/callouts.rb +104 -0
  113. data/_plugins/code_blocks.rb +277 -0
  114. data/_plugins/computation_figure_images.rb +266 -0
  115. data/_plugins/computation_sources.rb +42 -0
  116. data/_plugins/content_search_index.rb +210 -0
  117. data/_plugins/details.rb +23 -0
  118. data/_plugins/external-posts.rb +124 -0
  119. data/_plugins/figure_captions.rb +800 -0
  120. data/_plugins/file-exists.rb +22 -0
  121. data/_plugins/google-scholar-citations.rb +86 -0
  122. data/_plugins/hide-custom-bibtex.rb +18 -0
  123. data/_plugins/inspirehep-citations.rb +57 -0
  124. data/_plugins/localized_visual_sources.rb +145 -0
  125. data/_plugins/manual_release_metadata.rb +47 -0
  126. data/_plugins/mermaid_mmd_images.rb +233 -0
  127. data/_plugins/profile-pages.rb +28 -0
  128. data/_plugins/remove-accents.rb +32 -0
  129. data/_plugins/reproducible_build_time.rb +55 -0
  130. data/_plugins/search-data.rb +83 -0
  131. data/_plugins/theme-cache-bust.rb +56 -0
  132. data/_plugins/vega_visual_images.rb +290 -0
  133. data/_plugins/web_capture_images.rb +154 -0
  134. data/_sass/_base.scss +2133 -0
  135. data/_sass/_blog.scss +67 -0
  136. data/_sass/_callouts.scss +100 -0
  137. data/_sass/_custom.scss +437 -0
  138. data/_sass/_cv.scss +274 -0
  139. data/_sass/_developer.scss +97 -0
  140. data/_sass/_distill.scss +185 -0
  141. data/_sass/_documentation.scss +2220 -0
  142. data/_sass/_layout.scss +218 -0
  143. data/_sass/_manual.scss +2691 -0
  144. data/_sass/_profile.scss +323 -0
  145. data/_sass/_projects.scss +163 -0
  146. data/_sass/_reading.scss +465 -0
  147. data/_sass/_site-custom.scss +3 -0
  148. data/_sass/_tabs.scss +48 -0
  149. data/_sass/_team.scss +230 -0
  150. data/_sass/_themes.scss +254 -0
  151. data/_sass/_typograms.scss +132 -0
  152. data/_sass/_variables.scss +52 -0
  153. data/_sass/font-awesome/_animated.scss +152 -0
  154. data/_sass/font-awesome/_bordered-pulled.scss +20 -0
  155. data/_sass/font-awesome/_core.scss +49 -0
  156. data/_sass/font-awesome/_fixed-width.scss +7 -0
  157. data/_sass/font-awesome/_functions.scss +57 -0
  158. data/_sass/font-awesome/_icons.scss +13 -0
  159. data/_sass/font-awesome/_list.scss +18 -0
  160. data/_sass/font-awesome/_mixins.scss +65 -0
  161. data/_sass/font-awesome/_rotated-flipped.scss +31 -0
  162. data/_sass/font-awesome/_screen-reader.scss +14 -0
  163. data/_sass/font-awesome/_shims.scss +1578 -0
  164. data/_sass/font-awesome/_sizing.scss +16 -0
  165. data/_sass/font-awesome/_stacked.scss +32 -0
  166. data/_sass/font-awesome/_variables.scss +5044 -0
  167. data/_sass/font-awesome/brands.scss +30 -0
  168. data/_sass/font-awesome/fontawesome.scss +21 -0
  169. data/_sass/font-awesome/regular.scss +27 -0
  170. data/_sass/font-awesome/solid.scss +27 -0
  171. data/_sass/font-awesome/v4-shims.scss +11 -0
  172. data/_sass/tabler-icons/tabler-icons-filled.scss +1646 -0
  173. data/_sass/tabler-icons/tabler-icons-outline.scss +9859 -0
  174. data/_sass/tabler-icons/tabler-icons.scss +11463 -0
  175. data/_scripts/cronitor-analytics-setup.js +9 -0
  176. data/_scripts/giscus-setup.js +48 -0
  177. data/_scripts/google-analytics-setup.js +9 -0
  178. data/_scripts/open-panel-analytics-setup.js +14 -0
  179. data/_scripts/photoswipe-setup.js +11 -0
  180. data/assets/audio/epicaly-short-113909.mp3 +0 -0
  181. data/assets/bibliography/2018-12-22-distill.bib +7 -0
  182. data/assets/css/academicons.min.css +1 -0
  183. data/assets/css/bootstrap-toc.min.css +4 -0
  184. data/assets/css/bootstrap.min.css +7 -0
  185. data/assets/css/bootstrap.min.css.map +1 -0
  186. data/assets/css/jekyll-pygments-themes-github.css +209 -0
  187. data/assets/css/jekyll-pygments-themes-native.css +232 -0
  188. data/assets/css/jupyter-grade3.css +3758 -0
  189. data/assets/css/jupyter-monokai.css +3758 -0
  190. data/assets/css/jupyter.css +383 -0
  191. data/assets/css/main.scss +36 -0
  192. data/assets/css/mdb.min.css +48 -0
  193. data/assets/css/mdb.min.css.map +1 -0
  194. data/assets/css/scholar-icons.css +386 -0
  195. data/assets/fonts/academicons.ttf +0 -0
  196. data/assets/fonts/academicons.woff +0 -0
  197. data/assets/fonts/scholar-icons.ttf +0 -0
  198. data/assets/fonts/scholar-icons.woff +0 -0
  199. data/assets/fonts/tabler-icons-filled.ttf +0 -0
  200. data/assets/fonts/tabler-icons-filled.woff +0 -0
  201. data/assets/fonts/tabler-icons-filled.woff2 +0 -0
  202. data/assets/fonts/tabler-icons-outline.ttf +0 -0
  203. data/assets/fonts/tabler-icons-outline.woff +0 -0
  204. data/assets/fonts/tabler-icons-outline.woff2 +0 -0
  205. data/assets/fonts/tabler-icons.ttf +0 -0
  206. data/assets/fonts/tabler-icons.woff +0 -0
  207. data/assets/fonts/tabler-icons.woff2 +0 -0
  208. data/assets/html/relativity.html +15 -0
  209. data/assets/img/1.jpg +0 -0
  210. data/assets/img/10.jpg +0 -0
  211. data/assets/img/11.jpg +0 -0
  212. data/assets/img/12.jpg +0 -0
  213. data/assets/img/2.jpg +0 -0
  214. data/assets/img/3.jpg +0 -0
  215. data/assets/img/4.jpg +0 -0
  216. data/assets/img/5.jpg +0 -0
  217. data/assets/img/6.jpg +0 -0
  218. data/assets/img/7.jpg +0 -0
  219. data/assets/img/8.jpg +0 -0
  220. data/assets/img/9.jpg +0 -0
  221. data/assets/img/book_covers/the_godfather.jpg +0 -0
  222. data/assets/img/brand/dosquartsdedocs-logo-cafe.svg +31 -0
  223. data/assets/img/brand/dosquartsdedocs-logo-footer-cafe.svg +31 -0
  224. data/assets/img/brand/dosquartsdedocs-logo-white.svg +31 -0
  225. data/assets/img/brand/dosquartsdedocs-logo.svg +31 -0
  226. data/assets/img/prof_pic.jpg +0 -0
  227. data/assets/img/prof_pic_color.png +0 -0
  228. data/assets/img/profile-placeholder.svg +8 -0
  229. data/assets/img/publication_preview/brownian-motion.gif +0 -0
  230. data/assets/img/publication_preview/goodchild1992-gis-cover.jpg +0 -0
  231. data/assets/img/publication_preview/gutierrez2020-jtrg-preview.jpg +0 -0
  232. data/assets/img/publication_preview/harvey1989-postmodernity-cover.jpg +0 -0
  233. data/assets/img/publication_preview/tobler1970-economic-geography-cover.jpg +0 -0
  234. data/assets/img/publication_preview/tuan1977-space-place-cover.jpg +0 -0
  235. data/assets/img/publication_preview/wave-mechanics.gif +0 -0
  236. data/assets/img/publication_preview/zaragozi2025-rooftop-pv-preview.jpg +0 -0
  237. data/assets/img/rhino.png +0 -0
  238. data/assets/img/team/aaron-gutierrez.png +0 -0
  239. data/assets/img/team/alba-font.png +0 -0
  240. data/assets/img/team/alba-viana.png +0 -0
  241. data/assets/img/team/alejandro-gonzalez.png +0 -0
  242. data/assets/img/team/ana-pastor.png +0 -0
  243. data/assets/img/team/antoni-domenech.png +0 -0
  244. data/assets/img/team/benito-zaragozi.png +0 -0
  245. data/assets/img/team/cinta-sanz.png +0 -0
  246. data/assets/img/team/daniel-miravet.png +0 -0
  247. data/assets/img/team/eugenia-altamirano.png +0 -0
  248. data/assets/img/team/fiammetta-brandajs.png +0 -0
  249. data/assets/img/team/francesc-gonzalez.png +0 -0
  250. data/assets/img/team/isabel-paulino.png +0 -0
  251. data/assets/img/team/joan-borras.png +0 -0
  252. data/assets/img/team/julie-wilson.png +0 -0
  253. data/assets/img/team/liliana-sole.png +0 -0
  254. data/assets/img/team/maria-casado.png +0 -0
  255. data/assets/img/team/maria-medina.png +0 -0
  256. data/assets/img/team/marta-nello.png +0 -0
  257. data/assets/img/team/mogrify +1 -0
  258. data/assets/img/team/oscar-saladie.png +0 -0
  259. data/assets/img/team/paolo-russo.png +0 -0
  260. data/assets/img/team/raquel-santos.png +0 -0
  261. data/assets/img/team/salvador-anton.png +0 -0
  262. data/assets/img/team/sunsi-huertas.png +0 -0
  263. data/assets/img/team/user-placeholder.png +0 -0
  264. data/assets/img/team/wilbert-den-hoed.png +0 -0
  265. data/assets/img/team/xavier-delclos.png +0 -0
  266. data/assets/img/template_error.png +0 -0
  267. data/assets/js/bibsearch.js +70 -0
  268. data/assets/js/bootstrap-toc.min.js +5 -0
  269. data/assets/js/bootstrap.bundle.min.js +7 -0
  270. data/assets/js/bootstrap.bundle.min.js.map +1 -0
  271. data/assets/js/callouts.js +115 -0
  272. data/assets/js/chartjs-setup.js +14 -0
  273. data/assets/js/common.js +117 -0
  274. data/assets/js/content-search-match.js +159 -0
  275. data/assets/js/content-search.js +441 -0
  276. data/assets/js/copy_code.js +60 -0
  277. data/assets/js/custom.js +6 -0
  278. data/assets/js/developer-mode.js +103 -0
  279. data/assets/js/diff2html-setup.js +20 -0
  280. data/assets/js/distillpub/overrides.js +27 -0
  281. data/assets/js/distillpub/template.v2.js +9616 -0
  282. data/assets/js/distillpub/template.v2.js.map +1 -0
  283. data/assets/js/distillpub/transforms.v2.js +14557 -0
  284. data/assets/js/distillpub/transforms.v2.js.map +1 -0
  285. data/assets/js/documentation.js +435 -0
  286. data/assets/js/echarts-setup.js +29 -0
  287. data/assets/js/highlight-search-term.js +110 -0
  288. data/assets/js/jupyter_new_tab.js +18 -0
  289. data/assets/js/leaflet-setup.js +22 -0
  290. data/assets/js/manual.js +308 -0
  291. data/assets/js/masonry.js +12 -0
  292. data/assets/js/mathjax-setup.js +46 -0
  293. data/assets/js/mermaid-setup.js +37 -0
  294. data/assets/js/newsletter.js +105 -0
  295. data/assets/js/no_defer.js +24 -0
  296. data/assets/js/plotly-setup.js +52 -0
  297. data/assets/js/profile-nav.js +92 -0
  298. data/assets/js/progress-bar.js +73 -0
  299. data/assets/js/pseudocode-setup.js +18 -0
  300. data/assets/js/search/base-styles.min.js +286 -0
  301. data/assets/js/search/command-score.min.js +7 -0
  302. data/assets/js/search/hotkeys-js/hotkeys.esm.min.js +18 -0
  303. data/assets/js/search/lit/decorators.min.js +7 -0
  304. data/assets/js/search/lit/directives/class-map.min.js +7 -0
  305. data/assets/js/search/lit/directives/join.min.js +7 -0
  306. data/assets/js/search/lit/directives/live.min.js +7 -0
  307. data/assets/js/search/lit/directives/ref.min.js +7 -0
  308. data/assets/js/search/lit/directives/repeat.min.js +7 -0
  309. data/assets/js/search/lit/directives/unsafe-html.min.js +7 -0
  310. data/assets/js/search/lit/index.min.js +7 -0
  311. data/assets/js/search/lit-element/lit-element.min.js +12 -0
  312. data/assets/js/search/lit-html/directives/class-map.min.js +22 -0
  313. data/assets/js/search/lit-html/directives/join.min.js +12 -0
  314. data/assets/js/search/lit-html/directives/live.min.js +28 -0
  315. data/assets/js/search/lit-html/directives/ref.min.js +32 -0
  316. data/assets/js/search/lit-html/directives/repeat.min.js +27 -0
  317. data/assets/js/search/lit-html/directives/unsafe-html.min.js +22 -0
  318. data/assets/js/search/lit-html/is-server.min.js +12 -0
  319. data/assets/js/search/lit-html/lit-html.min.js +12 -0
  320. data/assets/js/search/ninja-action.min.js +101 -0
  321. data/assets/js/search/ninja-footer.min.js +62 -0
  322. data/assets/js/search/ninja-header.min.js +78 -0
  323. data/assets/js/search/ninja-keys.min.js +39 -0
  324. data/assets/js/search/reactive-element/decorators/custom-element.min.js +12 -0
  325. data/assets/js/search/reactive-element/decorators/event-options.min.js +12 -0
  326. data/assets/js/search/reactive-element/decorators/property.min.js +22 -0
  327. data/assets/js/search/reactive-element/decorators/query-all.min.js +17 -0
  328. data/assets/js/search/reactive-element/decorators/query-assigned-elements.min.js +17 -0
  329. data/assets/js/search/reactive-element/decorators/query-assigned-nodes.min.js +17 -0
  330. data/assets/js/search/reactive-element/decorators/query-async.min.js +17 -0
  331. data/assets/js/search/reactive-element/decorators/query.min.js +17 -0
  332. data/assets/js/search/reactive-element/decorators/state.min.js +27 -0
  333. data/assets/js/search/reactive-element/reactive-element.min.js +17 -0
  334. data/assets/js/search-setup.js +17 -0
  335. data/assets/js/shortcut-key.js +11 -0
  336. data/assets/js/tabs.min.js +5 -0
  337. data/assets/js/theme.js +314 -0
  338. data/assets/js/tooltips-setup.js +3 -0
  339. data/assets/js/typograms.js +1341 -0
  340. data/assets/js/vanilla-back-to-top.min.js +1 -0
  341. data/assets/js/vega-setup.js +24 -0
  342. data/assets/js/venobox-setup.js +5 -0
  343. data/assets/js/wechat.js +14 -0
  344. data/assets/js/zoom.js +15 -0
  345. data/assets/json/resume.json +98 -0
  346. data/assets/json/table_data.json +128 -0
  347. data/assets/jupyter/blog.ipynb +48 -0
  348. data/assets/pdf/example_pdf.pdf +0 -0
  349. data/assets/plotly/demo.html +72 -0
  350. data/assets/video/pexels-engin-akyurt-6069112-960x540-30fps.mp4 +0 -0
  351. data/assets/webfonts/fa-brands-400.ttf +0 -0
  352. data/assets/webfonts/fa-brands-400.woff2 +0 -0
  353. data/assets/webfonts/fa-regular-400.ttf +0 -0
  354. data/assets/webfonts/fa-regular-400.woff2 +0 -0
  355. data/assets/webfonts/fa-solid-900.ttf +0 -0
  356. data/assets/webfonts/fa-solid-900.woff2 +0 -0
  357. data/assets/webfonts/fa-v4compatibility.ttf +0 -0
  358. data/assets/webfonts/fa-v4compatibility.woff2 +0 -0
  359. data/docs/Gemfile +5 -0
  360. data/docs/_config.yml +83 -0
  361. data/docs/_documentation/en/01-quick-start.md +115 -0
  362. data/docs/_documentation/en/02-tools.md +146 -0
  363. data/docs/_documentation/en/03-usage.md +107 -0
  364. data/docs/_documentation/en/04-jekyll-basics.md +51 -0
  365. data/docs/_documentation/en/05-pages-collections.md +68 -0
  366. data/docs/_documentation/en/06-github-web-editing.md +92 -0
  367. data/docs/_documentation/en/10-profiles.md +125 -0
  368. data/docs/_documentation/en/11-unaltreselfie.md +39 -0
  369. data/docs/_documentation/en/12-unaltreprojecte.md +38 -0
  370. data/docs/_documentation/en/13-unaltremanual.md +237 -0
  371. data/docs/_documentation/en/14-unaltredocs.md +92 -0
  372. data/docs/_documentation/en/15-profile-checklist.md +39 -0
  373. data/docs/_documentation/en/20-syntax.md +353 -0
  374. data/docs/_documentation/en/21-themes.md +103 -0
  375. data/docs/_documentation/en/22-feature-reference.md +56 -0
  376. data/docs/_documentation/en/23-front-matter.md +51 -0
  377. data/docs/_documentation/en/24-data-files.md +28 -0
  378. data/docs/_documentation/en/30-customization.md +448 -0
  379. data/docs/_documentation/en/31-template.md +55 -0
  380. data/docs/_documentation/en/32-development.md +108 -0
  381. data/docs/_documentation/en/33-reusable-workflows.md +136 -0
  382. data/docs/_documentation/en/40-distribution.md +164 -0
  383. data/docs/_documentation/en/41-bibliometrics.md +137 -0
  384. data/docs/_documentation/en/42-docker-image.md +185 -0
  385. data/docs/_documentation/en/50-faq.md +33 -0
  386. data/docs/_pages/ca/index.md +35 -0
  387. data/docs/_pages/en/index.md +35 -0
  388. data/docs/_pages/es/index.md +35 -0
  389. data/docs/agents/action-prompts/00-start-site-session.txt +10 -0
  390. data/docs/agents/action-prompts/05-create-new-web.txt +5 -0
  391. data/docs/agents/action-prompts/10-content-update.txt +9 -0
  392. data/docs/agents/action-prompts/15-edit-default-content.txt +8 -0
  393. data/docs/agents/action-prompts/20-manual-teaching-materials.txt +18 -0
  394. data/docs/agents/action-prompts/22-manual-style-audit.txt +14 -0
  395. data/docs/agents/action-prompts/23-manual-structure-audit.txt +17 -0
  396. data/docs/agents/action-prompts/25-translation-prepublish.txt +8 -0
  397. data/docs/agents/action-prompts/30-project-site-update.txt +7 -0
  398. data/docs/agents/action-prompts/40-documentation-update.txt +7 -0
  399. data/docs/agents/action-prompts/50-bibliography-entry.txt +7 -0
  400. data/docs/agents/action-prompts/60-bibliometrics-refresh.txt +7 -0
  401. data/docs/agents/action-prompts/70-build-and-review.txt +12 -0
  402. data/docs/agents/manual-authoring-components.md +255 -0
  403. data/docs/agents/mcp-contract.md +162 -0
  404. data/docs/agents/quarto-computation-adoption.md +102 -0
  405. data/docs/assets/img/unaltraweb-docs-hero.svg +36 -0
  406. data/lib/unaltraweb/version.rb +8 -0
  407. data/lib/unaltraweb.rb +31 -0
  408. data/requirements.txt +5 -0
  409. data/scripts/biblio/fetch_scimago_csv.sh +126 -0
  410. data/scripts/biblio/metrics_common.py +154 -0
  411. data/scripts/biblio/metrics_merge_meta.py +73 -0
  412. data/scripts/biblio/metrics_update.py +826 -0
  413. data/scripts/computations/python/Dockerfile +37 -0
  414. data/scripts/computations/r/Dockerfile +31 -0
  415. data/scripts/computations/render.py +1267 -0
  416. data/scripts/cv/render_pdf_preview.sh +36 -0
  417. data/scripts/deploy/publish_branch.sh +80 -0
  418. data/scripts/generate_logo_variants.py +47 -0
  419. data/scripts/manual/Dockerfile +31 -0
  420. data/scripts/manual/build_pdf.py +1780 -0
  421. data/scripts/manual/filters/bibliography.lua +164 -0
  422. data/scripts/manual/filters/code-blocks.lua +102 -0
  423. data/scripts/manual/filters/figure-captions.lua +35 -0
  424. data/scripts/manual/publish_release.sh +281 -0
  425. data/scripts/manual/templates/manual.tex +494 -0
  426. data/scripts/manual/verify_release_assets.py +650 -0
  427. data/scripts/test_gem_build.py +128 -0
  428. data/scripts/test_reproducible_jekyll_build.py +191 -0
  429. data/scripts/test_wheel_install.py +282 -0
  430. data/scripts/unaltraweb-docker-mount.sh +36 -0
  431. data/scripts/unaltraweb-mcp-bootstrap.sh +105 -0
  432. data/scripts/unaltraweb-mcp-cleanup.sh +73 -0
  433. data/scripts/unaltraweb-mcp-project-id.sh +34 -0
  434. data/scripts/validate_distribution.py +563 -0
  435. data/scripts/validate_workflows.py +1285 -0
  436. data/scripts/web_captures/Dockerfile +31 -0
  437. data/scripts/web_captures/capture.mjs +157 -0
  438. data/scripts/web_captures/package-lock.json +56 -0
  439. data/scripts/web_captures/package.json +9 -0
  440. data/scripts/web_captures/render.py +867 -0
  441. data/src/unaltraweb_mcp/component-contract.json +153 -0
  442. data/src/unaltraweb_mcp/component-contract.schema.json +87 -0
  443. data/src/unaltraweb_mcp/docker_mount.py +19 -0
  444. metadata +861 -0
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env python3
2
+ """Validate release parity, component pins, and the modular wheel boundary."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ try:
17
+ import tomllib
18
+ except ModuleNotFoundError: # Python 3.10 package dependency.
19
+ import tomli as tomllib
20
+
21
+
22
+ ROOT = Path(__file__).resolve().parents[1]
23
+ RELEASE_CANDIDATES_PATH = Path("release-candidates.json")
24
+ sys.path.insert(0, str(ROOT))
25
+ sys.path.insert(0, str(ROOT / "src"))
26
+
27
+ from unaltraweb_mcp import __version__ # noqa: E402
28
+ from unaltraweb_mcp.cli import ( # noqa: E402
29
+ FACTORY_REQUIRED_COMMANDS,
30
+ FACTORY_REQUIRED_MCP_COMMANDS,
31
+ PACKAGE_ONLY_COMMANDS,
32
+ PACKAGE_ONLY_MCP_COMMANDS,
33
+ )
34
+ from unaltraweb_mcp.distribution import ( # noqa: E402
35
+ companion_dependency_requirements,
36
+ component_contract_semantic_errors,
37
+ component_reference,
38
+ distribution_contract,
39
+ is_mutable_reference,
40
+ validate_component_contract,
41
+ )
42
+ from scripts.validate_workflows import load_workflow_text, reusable_deploy_input_errors # noqa: E402
43
+
44
+
45
+ def load_yaml(path: Path) -> dict[str, Any]:
46
+ try:
47
+ import yaml # type: ignore
48
+
49
+ class UniqueKeyLoader(yaml.SafeLoader):
50
+ pass
51
+
52
+ def construct_mapping(loader, node, deep=False):
53
+ mapping: dict[Any, Any] = {}
54
+ for key_node, value_node in node.value:
55
+ key = loader.construct_object(key_node, deep=deep)
56
+ if key in mapping:
57
+ raise ValueError(f"duplicate YAML key: {key}")
58
+ mapping[key] = loader.construct_object(value_node, deep=deep)
59
+ return mapping
60
+
61
+ UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct_mapping)
62
+ value = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader)
63
+ except Exception as exc:
64
+ raise RuntimeError(f"Could not read {path}: {exc}") from exc
65
+ return value if isinstance(value, dict) else {}
66
+
67
+
68
+ def make_value(path: Path, variable: str) -> str:
69
+ text = path.read_text(encoding="utf-8")
70
+ match = re.search(rf"(?m)^{re.escape(variable)}\s*\?[:+]?=\s*([^\s#]+)", text)
71
+ return match.group(1).strip('"\'') if match else ""
72
+
73
+
74
+ def validate(root: Path = ROOT) -> list[str]:
75
+ errors: list[str] = []
76
+ contract = distribution_contract()
77
+ version = str(contract["release"]["version"])
78
+ required_components = {
79
+ "gem", "wheel", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals"
80
+ }
81
+ if contract.get("schema_version") != 1:
82
+ errors.append("component contract schema_version must be 1")
83
+ if set(contract.get("components", {})) != required_components:
84
+ errors.append("component contract inventory is incomplete")
85
+ dockerignore = (root / ".dockerignore").read_text(encoding="utf-8").splitlines()
86
+ if RELEASE_CANDIDATES_PATH.as_posix() not in dockerignore:
87
+ errors.append("release-candidates.json must be excluded from Docker build contexts")
88
+ if __version__ != version:
89
+ errors.append(f"wheel version {__version__} != contract version {version}")
90
+ for component_id in ["gem", "wheel", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf"]:
91
+ if str(contract["components"][component_id]["version"]) != version:
92
+ errors.append(f"{component_id} version does not match release {version}")
93
+ included = {name for name, item in contract["components"].items() if item["included_in_wheel"]}
94
+ if included != {"wheel"}:
95
+ errors.append(f"wheel must not bundle external components: {sorted(included)}")
96
+ wheel_contract = contract["wheel_contract"]
97
+ if set(wheel_contract["package_only_commands"]) != PACKAGE_ONLY_COMMANDS:
98
+ errors.append("wheel package-only command inventory does not match the CLI")
99
+ if set(wheel_contract["factory_required_commands"]) != FACTORY_REQUIRED_COMMANDS:
100
+ errors.append("wheel factory-required command inventory does not match the CLI")
101
+ if set(wheel_contract["package_only_mcp"]) != PACKAGE_ONLY_MCP_COMMANDS:
102
+ errors.append("wheel package-only MCP inventory does not match the CLI")
103
+ if set(wheel_contract["factory_required_mcp"]) != FACTORY_REQUIRED_MCP_COMMANDS:
104
+ errors.append("wheel factory-required MCP inventory does not match the CLI")
105
+
106
+ schema = json.loads((root / "src/unaltraweb_mcp/component-contract.schema.json").read_text(encoding="utf-8"))
107
+ try:
108
+ validate_component_contract(contract, schema)
109
+ except RuntimeError as exc:
110
+ errors.append(f"component contract schema validation failed: {exc}")
111
+ errors.extend(f"component contract semantic validation failed: {error}" for error in component_contract_semantic_errors(contract))
112
+ core_sha = str(contract["consumer_integration"]["core_sha"])
113
+ core_object = subprocess.run(
114
+ ["git", "cat-file", "-e", f"{core_sha}^{{commit}}"],
115
+ cwd=root,
116
+ stdout=subprocess.PIPE,
117
+ stderr=subprocess.PIPE,
118
+ check=False,
119
+ )
120
+ if core_object.returncode != 0:
121
+ errors.append("consumer integration core SHA is not a commit in this repository")
122
+ else:
123
+ core_ancestor = subprocess.run(
124
+ ["git", "merge-base", "--is-ancestor", core_sha, "HEAD"],
125
+ cwd=root,
126
+ stdout=subprocess.PIPE,
127
+ stderr=subprocess.PIPE,
128
+ check=False,
129
+ )
130
+ if core_ancestor.returncode != 0:
131
+ errors.append("consumer integration core SHA must be an ancestor of the current source")
132
+ pinned_workflow = subprocess.run(
133
+ ["git", "show", f"{core_sha}:.github/workflows/site-deploy.yml"],
134
+ cwd=root,
135
+ text=True,
136
+ stdout=subprocess.PIPE,
137
+ stderr=subprocess.PIPE,
138
+ check=False,
139
+ )
140
+ if pinned_workflow.returncode != 0:
141
+ errors.append("consumer integration core SHA does not contain the reusable deploy workflow")
142
+ else:
143
+ try:
144
+ pinned_deploy = load_workflow_text(pinned_workflow.stdout)
145
+ except Exception as exc:
146
+ errors.append(f"consumer integration workflow is invalid YAML: {exc}")
147
+ else:
148
+ errors.extend(
149
+ f"consumer integration workflow: {error}"
150
+ for error in reusable_deploy_input_errors(pinned_deploy)
151
+ )
152
+ if schema.get("properties", {}).get("schema_version", {}).get("const") != 1:
153
+ errors.append("component contract schema does not select version 1")
154
+
155
+ pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
156
+ project = pyproject.get("project", {})
157
+ if "version" in project or project.get("dynamic") != ["version"]:
158
+ errors.append("pyproject version must be derived from the package component contract")
159
+ if pyproject.get("tool", {}).get("setuptools", {}).get("dynamic", {}).get("version", {}).get("attr") != "unaltraweb_mcp.__version__":
160
+ errors.append("setuptools dynamic version is not wired to unaltraweb_mcp.__version__")
161
+ ruby_version_source = (root / "lib/unaltraweb/version.rb").read_text(encoding="utf-8")
162
+ if "component-contract.json" not in ruby_version_source or re.search(r'VERSION\s*=\s*["\']\d', ruby_version_source):
163
+ errors.append("gem version must be derived from the package component contract")
164
+ gemspec = (root / "unaltraweb.gemspec").read_text(encoding="utf-8")
165
+ if 'spec.required_ruby_version = ">= 3.2"' not in gemspec:
166
+ errors.append("gemspec Ruby requirement must support the Bundler 4 scaffold lock")
167
+ for asset in ["component-contract.json", "component-contract.schema.json"]:
168
+ if gemspec.count(f'"src/unaltraweb_mcp/{asset}"') < 2:
169
+ errors.append(f"gemspec must discover and select {asset}")
170
+ runtime_base = (root / "Dockerfile").read_text(encoding="utf-8").splitlines()[0]
171
+ if not re.fullmatch(r"FROM ruby:[0-9]+\.[0-9]+\.[0-9]+-slim@sha256:[0-9a-f]{64}", runtime_base):
172
+ errors.append("Dockerfile runtime base must pin an exact Ruby patch version and image digest")
173
+
174
+ manifest = load_yaml(root / "mcp-factory.yml")
175
+ if str(manifest.get("version")) != version:
176
+ errors.append("mcp-factory.yml version does not match the component contract")
177
+ if str(manifest.get("release", {}).get("default")) != contract["release"]["tag"]:
178
+ errors.append("mcp-factory.yml release.default does not match the component contract")
179
+ if str(manifest.get("runtime", {}).get("image")) != component_reference("mcp"):
180
+ errors.append("mcp-factory.yml runtime image does not match the component contract")
181
+
182
+ dependencies = {
183
+ str(item.get("name")): item
184
+ for item in manifest.get("mcp_dependencies", [])
185
+ if isinstance(item, dict)
186
+ }
187
+ for component_id in ["diavisuals", "vegavisuals"]:
188
+ expected = contract["components"][component_id]
189
+ actual = dependencies.get(component_id, {})
190
+ requirements = companion_dependency_requirements(component_id)
191
+ expected_remote = expected["repository"] + ".git"
192
+ for key, selected in [
193
+ ("version", expected["version"]),
194
+ ("release", expected["release"]),
195
+ ("release_status", expected["release_status"]),
196
+ ("remote", expected_remote),
197
+ ]:
198
+ if str(actual.get(key) or "") != selected:
199
+ errors.append(f"{component_id} {key} does not match the selected companion release")
200
+ if not str(actual.get("remote") or "").startswith("https://"):
201
+ errors.append(f"{component_id} remote must use HTTPS")
202
+ for key, selected in requirements["lifecycle"].items():
203
+ if actual.get(key) is not selected:
204
+ errors.append(f"{component_id} lifecycle flag {key} must be {selected}")
205
+ if actual.get("uv_spec") != requirements["uv_spec"]:
206
+ errors.append(f"{component_id} uv_spec must select its immutable companion release")
207
+ missing_tools = set(requirements["tools"]) - set(actual.get("required_tools", []))
208
+ missing_resources = set(requirements["resources"]) - set(actual.get("required_resources", []))
209
+ if missing_tools:
210
+ errors.append(f"{component_id} dependency is missing required tools: {sorted(missing_tools)}")
211
+ if missing_resources:
212
+ errors.append(f"{component_id} dependency is missing required resources: {sorted(missing_resources)}")
213
+
214
+ make_pins = {
215
+ "MCP_RUNTIME_IMAGE": "runtime",
216
+ "MCP_IMAGE": "mcp",
217
+ "WEB_CAPTURE_IMAGE": "web_capture",
218
+ "DOCKER_IMAGE": "runtime",
219
+ "MANUAL_PDF_IMAGE": "manual_pdf",
220
+ }
221
+ for variable, component_id in make_pins.items():
222
+ actual = make_value(root / "Makefile", variable)
223
+ expected = component_reference(component_id)
224
+ if actual != expected:
225
+ errors.append(f"Makefile {variable}={actual or 'missing'} != {expected}")
226
+
227
+ pinned_text = {
228
+ "scripts/computations/render.py": [component_reference("compute_python"), component_reference("compute_r")],
229
+ "scripts/web_captures/render.py": [component_reference("web_capture")],
230
+ "scripts/unaltraweb-mcp-bootstrap.sh": [component_reference("mcp")],
231
+ "Dockerfile.mcp": [component_reference("runtime")],
232
+ }
233
+ for relative, references in pinned_text.items():
234
+ text = (root / relative).read_text(encoding="utf-8")
235
+ for reference in references:
236
+ if reference not in text:
237
+ errors.append(f"{relative} does not contain selected reference {reference}")
238
+ if re.search(r"ghcr\.io/dosquartsdedocs/[^\s\"']+:(?:main|latest)\b", text):
239
+ errors.append(f"{relative} contains a mutable production default")
240
+
241
+ scaffold_templates = {
242
+ "Gemfile.tmpl": ["__GEM_VERSION__", "__CORE_REPOSITORY__", "__CORE_SHA__"],
243
+ "Gemfile.lock.tmpl": ["__GEM_VERSION__", "__CORE_REPOSITORY__", "__CORE_SHA__"],
244
+ "Makefile.tmpl": ["__MCP_IMAGE__"],
245
+ ".github/workflows/deploy.yml.tmpl": [
246
+ "__SITE_DEPLOY_WORKFLOW__",
247
+ "__CORE_SHA__",
248
+ "__MANUAL_PDF_IMAGE__",
249
+ "__VEGAVISUALS_SHA__",
250
+ ],
251
+ ".github/dependabot.yml.tmpl": ["__SITE_DEPLOY_WORKFLOW__"],
252
+ }
253
+ for name, tokens in scaffold_templates.items():
254
+ text = (root / "src/unaltraweb_mcp/scaffolds/common" / name).read_text(encoding="utf-8")
255
+ for token in tokens:
256
+ if token not in text:
257
+ errors.append(f"scaffold {name} must derive {token} from the component contract")
258
+ computation_template = (root / "src/unaltraweb_mcp/scaffolds/profiles/unaltremanual/computations.yml.tmpl").read_text(encoding="utf-8")
259
+ for token in ["__COMPUTE_PYTHON_IMAGE__", "__COMPUTE_R_IMAGE__"]:
260
+ if token not in computation_template:
261
+ errors.append(f"unaltremanual computation scaffold is missing component token {token}")
262
+
263
+ release_references = [
264
+ item["reference"]
265
+ for item in contract["components"].values()
266
+ if item["kind"] in {"container", "companion"}
267
+ ]
268
+ for reference in release_references:
269
+ if is_mutable_reference(str(reference)):
270
+ errors.append(f"component contract contains a mutable release reference: {reference}")
271
+
272
+ package_root = root / "src/unaltraweb_mcp"
273
+ forbidden = ["mcp-factory.yml", "Dockerfile", "Makefile", "docs", "scripts", "_layouts", "_includes", "_sass"]
274
+ for name in forbidden:
275
+ if (package_root / name).exists():
276
+ errors.append(f"wheel boundary contains factory asset: {name}")
277
+ if not (package_root / "scaffolds").is_dir():
278
+ errors.append("wheel boundary is missing package-owned scaffolds")
279
+
280
+ ruby = shutil.which("ruby")
281
+ if ruby:
282
+ completed = subprocess.run(
283
+ [ruby, "-Ilib", "-runaltraweb/version", "-e", "print Unaltraweb::VERSION"],
284
+ cwd=root,
285
+ text=True,
286
+ stdout=subprocess.PIPE,
287
+ stderr=subprocess.PIPE,
288
+ check=False,
289
+ )
290
+ if completed.returncode != 0 or completed.stdout != version:
291
+ errors.append(f"gem version parity failed: {completed.stderr.strip() or completed.stdout}")
292
+ return errors
293
+
294
+
295
+ def release_tag_status_errors(
296
+ contract: dict[str, Any],
297
+ *,
298
+ ref_type: str,
299
+ ref_name: str,
300
+ ) -> list[str]:
301
+ release_tag = str(contract.get("release", {}).get("tag") or "")
302
+ if ref_type != "tag" or ref_name != release_tag:
303
+ return []
304
+ errors: list[str] = []
305
+ for component_id, component in sorted(contract.get("components", {}).items()):
306
+ status = str(component.get("release_status") or "") if isinstance(component, dict) else ""
307
+ if status not in {"ready", "released"}:
308
+ errors.append(
309
+ f"{component_id} must be release-ready before publishing exact release tag {release_tag}; found {status or '<unset>'}"
310
+ )
311
+ return errors
312
+
313
+
314
+ def release_candidate_receipt_errors(
315
+ contract: dict[str, Any],
316
+ receipt: Any,
317
+ *,
318
+ parent_commit: str = "",
319
+ changed_paths: list[str] | None = None,
320
+ default_branch_ancestor: bool | None = None,
321
+ ) -> list[str]:
322
+ errors: list[str] = []
323
+ ready_components = {
324
+ component_id: component
325
+ for component_id, component in contract.get("components", {}).items()
326
+ if isinstance(component, dict) and component.get("release_status") == "ready"
327
+ }
328
+ if not isinstance(receipt, dict):
329
+ return ["release candidate receipt must be a JSON object"]
330
+ if receipt.get("schema_version") != 1:
331
+ errors.append("release candidate receipt schema_version must be 1")
332
+ release = contract.get("release", {})
333
+ if receipt.get("release") != release.get("tag"):
334
+ errors.append(f"release candidate receipt must select {release.get('tag')}")
335
+ source_commit = str(receipt.get("source_commit") or "")
336
+ if re.fullmatch(r"[0-9a-f]{40}", source_commit) is None:
337
+ errors.append("release candidate receipt source_commit must be a full lowercase Git SHA")
338
+ if parent_commit and source_commit != parent_commit:
339
+ errors.append("release candidate receipt source_commit must be the parent of the release metadata commit")
340
+ if default_branch_ancestor is False:
341
+ errors.append("release candidate source commit must belong to the default branch")
342
+ if changed_paths is not None and changed_paths != [RELEASE_CANDIDATES_PATH.as_posix()]:
343
+ errors.append("release metadata commit may change only release-candidates.json")
344
+
345
+ candidates = receipt.get("components")
346
+ if not isinstance(candidates, dict):
347
+ errors.append("release candidate receipt components must be an object")
348
+ candidates = {}
349
+ if set(candidates) != set(ready_components):
350
+ errors.append("release candidate receipt components must exactly match ready BOM components")
351
+ for component_id, component in ready_components.items():
352
+ candidate = candidates.get(component_id)
353
+ if not isinstance(candidate, dict):
354
+ continue
355
+ if component.get("kind") == "container":
356
+ reference = str(candidate.get("reference") or "")
357
+ image_repository = str(component.get("image_repository") or "")
358
+ if re.fullmatch(rf"{re.escape(image_repository)}@sha256:[0-9a-f]{{64}}", reference) is None:
359
+ errors.append(f"{component_id} candidate reference must use an immutable digest from {image_repository}")
360
+ if set(candidate) != {"reference"}:
361
+ errors.append(f"{component_id} container candidate may contain only reference")
362
+ elif component.get("kind") in {"gem", "python-wheel"}:
363
+ artifact = str(candidate.get("artifact") or "")
364
+ sha256 = str(candidate.get("sha256") or "")
365
+ expected_artifact = (
366
+ f"{component['name']}-{component['version']}.gem"
367
+ if component.get("kind") == "gem"
368
+ else f"{str(component['name']).replace('-', '_')}-{component['version']}-py3-none-any.whl"
369
+ )
370
+ if artifact != expected_artifact:
371
+ errors.append(f"{component_id} candidate artifact must be {expected_artifact}")
372
+ if re.fullmatch(r"[0-9a-f]{64}", sha256) is None:
373
+ errors.append(f"{component_id} candidate sha256 must be a lowercase SHA-256 digest")
374
+ if set(candidate) != {"artifact", "sha256"}:
375
+ errors.append(f"{component_id} package candidate must contain only artifact and sha256")
376
+ else:
377
+ errors.append(f"{component_id} must be released instead of using a candidate receipt")
378
+ return errors
379
+
380
+
381
+ def load_release_candidate_receipt_errors(root: Path, contract: dict[str, Any]) -> list[str]:
382
+ path = root / RELEASE_CANDIDATES_PATH
383
+ if not path.is_file():
384
+ return [f"missing release candidate receipt: {RELEASE_CANDIDATES_PATH}"]
385
+
386
+ def unique_object(pairs):
387
+ result: dict[str, Any] = {}
388
+ for key, value in pairs:
389
+ if key in result:
390
+ raise ValueError(f"duplicate JSON key: {key}")
391
+ result[key] = value
392
+ return result
393
+
394
+ try:
395
+ receipt = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
396
+ except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError) as exc:
397
+ return [f"invalid release candidate receipt: {exc}"]
398
+ parent = subprocess.run(
399
+ ["git", "rev-parse", "HEAD^"],
400
+ cwd=root,
401
+ text=True,
402
+ stdout=subprocess.PIPE,
403
+ stderr=subprocess.PIPE,
404
+ check=False,
405
+ )
406
+ if parent.returncode != 0:
407
+ return [f"could not inspect release candidate source commit: {parent.stderr.strip() or parent.stdout.strip()}"]
408
+ parent_commit = parent.stdout.strip()
409
+ changed = subprocess.run(
410
+ ["git", "diff", "--name-only", parent_commit, "HEAD"],
411
+ cwd=root,
412
+ text=True,
413
+ stdout=subprocess.PIPE,
414
+ stderr=subprocess.PIPE,
415
+ check=False,
416
+ )
417
+ if changed.returncode != 0:
418
+ return [f"could not inspect release metadata commit: {changed.stderr.strip() or changed.stdout.strip()}"]
419
+ changed_paths = [line for line in changed.stdout.splitlines() if line]
420
+ default_branch = os.environ.get("GITHUB_DEFAULT_BRANCH", "").strip()
421
+ if not default_branch:
422
+ symbolic = subprocess.run(
423
+ ["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
424
+ cwd=root,
425
+ text=True,
426
+ stdout=subprocess.PIPE,
427
+ stderr=subprocess.PIPE,
428
+ check=False,
429
+ )
430
+ if symbolic.returncode != 0 or not symbolic.stdout.strip().startswith("origin/"):
431
+ return ["could not determine the default branch for release candidate ancestry validation"]
432
+ default_branch = symbolic.stdout.strip().removeprefix("origin/")
433
+ ancestor = subprocess.run(
434
+ ["git", "merge-base", "--is-ancestor", parent_commit, f"origin/{default_branch}"],
435
+ cwd=root,
436
+ stdout=subprocess.PIPE,
437
+ stderr=subprocess.PIPE,
438
+ check=False,
439
+ )
440
+ if ancestor.returncode not in {0, 1}:
441
+ return [f"could not verify default-branch ancestry: {ancestor.stderr.decode().strip()}"]
442
+ default_branch_ancestor = ancestor.returncode == 0
443
+ return release_candidate_receipt_errors(
444
+ contract,
445
+ receipt,
446
+ parent_commit=parent_commit,
447
+ changed_paths=changed_paths,
448
+ default_branch_ancestor=default_branch_ancestor,
449
+ )
450
+
451
+
452
+ def publish_ref_errors(
453
+ contract: dict[str, Any],
454
+ *,
455
+ ref_type: str,
456
+ ref_name: str,
457
+ default_branch: str,
458
+ component_ids: list[str],
459
+ ) -> list[str]:
460
+ errors: list[str] = []
461
+ release = contract.get("release", {})
462
+ version = str(release.get("version") or "")
463
+ release_tag = str(release.get("tag") or "")
464
+ if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) or release_tag != f"v{version}":
465
+ errors.append("distribution release must use matching X.Y.Z and vX.Y.Z values")
466
+
467
+ components = contract.get("components", {})
468
+ for component_id in component_ids:
469
+ component = components.get(component_id)
470
+ if not isinstance(component, dict):
471
+ errors.append(f"unknown publish component: {component_id}")
472
+ continue
473
+ if str(component.get("version") or "") != version:
474
+ errors.append(f"{component_id} publish version does not match distribution release {version}")
475
+ if str(component.get("release") or "") != release_tag:
476
+ errors.append(f"{component_id} publish tag does not match distribution release {release_tag}")
477
+
478
+ if ref_type == "branch" and ref_name == default_branch and default_branch:
479
+ return errors
480
+ if ref_type == "tag" and ref_name == release_tag:
481
+ errors.extend(release_tag_status_errors(contract, ref_type=ref_type, ref_name=ref_name))
482
+ return errors
483
+ errors.append(f"publish ref must be the default branch {default_branch or '<unset>'} or exact release tag {release_tag or '<unset>'}")
484
+ return errors
485
+
486
+
487
+ def main(argv: list[str] | None = None) -> int:
488
+ parser = argparse.ArgumentParser(description=__doc__)
489
+ parser.add_argument(
490
+ "--require-release-ready",
491
+ action="store_true",
492
+ help="Fail when a selected component release is pending or unavailable.",
493
+ )
494
+ parser.add_argument(
495
+ "--validate-publish-ref",
496
+ action="store_true",
497
+ help="Require the default branch or exact distribution release tag; release tags require every component to be ready or released.",
498
+ )
499
+ parser.add_argument(
500
+ "--components",
501
+ default="",
502
+ help="Comma-separated component IDs whose versions and release tags must match before publication.",
503
+ )
504
+ args = parser.parse_args(argv)
505
+ errors = validate()
506
+ release_tag_errors: list[str] = []
507
+ candidate_receipt_errors: list[str] = []
508
+ other_publish_errors: list[str] = []
509
+ contract = distribution_contract()
510
+ if args.validate_publish_ref:
511
+ component_ids = [item.strip() for item in args.components.split(",") if item.strip()]
512
+ if not component_ids:
513
+ errors.append("--validate-publish-ref requires at least one --components value")
514
+ ref_type = os.environ.get("GITHUB_REF_TYPE", "")
515
+ ref_name = os.environ.get("GITHUB_REF_NAME", "")
516
+ release_tag_errors = release_tag_status_errors(contract, ref_type=ref_type, ref_name=ref_name)
517
+ publish_errors = publish_ref_errors(
518
+ contract,
519
+ ref_type=ref_type,
520
+ ref_name=ref_name,
521
+ default_branch=os.environ.get("GITHUB_DEFAULT_BRANCH", ""),
522
+ component_ids=component_ids,
523
+ )
524
+ other_publish_errors = [error for error in publish_errors if error not in release_tag_errors]
525
+ errors.extend(publish_errors)
526
+ pending = sorted(
527
+ component_id for component_id, selected in contract["components"].items()
528
+ if selected["release_status"] == "pending"
529
+ )
530
+ unavailable = sorted(
531
+ component_id for component_id, selected in contract["components"].items()
532
+ if selected["release_status"] == "unavailable"
533
+ )
534
+ ready = sorted(
535
+ component_id for component_id, selected in contract["components"].items()
536
+ if selected["release_status"] == "ready"
537
+ )
538
+ exact_release_tag = (
539
+ args.validate_publish_ref
540
+ and os.environ.get("GITHUB_REF_TYPE") == "tag"
541
+ and os.environ.get("GITHUB_REF_NAME") == contract.get("release", {}).get("tag")
542
+ )
543
+ if ready and (args.require_release_ready or exact_release_tag):
544
+ candidate_receipt_errors = load_release_candidate_receipt_errors(ROOT, contract)
545
+ errors.extend(candidate_receipt_errors)
546
+ print(json.dumps({
547
+ "ok": not errors,
548
+ "release_ready": not errors and not pending and not unavailable,
549
+ "errors": errors,
550
+ "pending_releases": pending,
551
+ "unavailable_releases": unavailable,
552
+ }, indent=2, sort_keys=True))
553
+ readiness_errors = release_tag_errors + candidate_receipt_errors
554
+ structural_errors = [error for error in errors if error not in readiness_errors]
555
+ if structural_errors or other_publish_errors:
556
+ return 1
557
+ if readiness_errors or (args.require_release_ready and (pending or unavailable)):
558
+ return 2
559
+ return 0
560
+
561
+
562
+ if __name__ == "__main__":
563
+ raise SystemExit(main())