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,1285 @@
1
+ #!/usr/bin/env python3
2
+ """Validate GitHub Actions syntax, immutable pins, and publication gates."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ WORKFLOW_ROOT = ROOT / ".github/workflows"
17
+ FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
18
+ JOB_ENV_RUNNER_CONTEXT = re.compile(
19
+ r"\${{.*?(?<![A-Za-z0-9_.])runner\s*(?:\.|\[).*?}}",
20
+ re.DOTALL,
21
+ )
22
+ DOCKER_BUILD_COMMAND = re.compile(r"\bdocker\s+(?:build|buildx\s+build|compose\s+build)(?:\s|$)")
23
+ CANDIDATE_EXECUTION_COMMAND = re.compile(
24
+ r"\bdocker\s+(?:(?:container)\s+)?(?:run|create|start|exec)(?:\s|$)"
25
+ r"|\bdocker\s+compose\s+(?:run|up|start|exec)(?:\s|$)"
26
+ )
27
+ CANDIDATE_EXECUTION_TARGETS = {
28
+ "docs-build",
29
+ "mcp-smoke-prebuilt",
30
+ "reproducible-site-check",
31
+ }
32
+ REVIEWED_ACTIONS = {
33
+ "actions/attest-build-provenance": "4d101475d8b20a2381f78447822ac1eab6504dd8",
34
+ "actions/checkout": "11d5960a326750d5838078e36cf38b85af677262",
35
+ "actions/configure-pages": "983d7736d9b0ae728b81ab479565c72886d7745b",
36
+ "actions/deploy-pages": "d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e",
37
+ "actions/download-artifact": "d3f86a106a0bac45b974a628896c90dbdf5c8093",
38
+ "actions/setup-python": "a26af69be951a213d495a4c3e4e4022e16d87065",
39
+ "actions/upload-artifact": "ea165f8d65b6e75b540449e92b4886f43607fa02",
40
+ "actions/upload-pages-artifact": "56afc609e74202658d3ffba0e8f6dda462b719fa",
41
+ "docker/build-push-action": "10e90e3645eae34f1e60eeb005ba3a3d33f178e8",
42
+ "docker/login-action": "c94ce9fb468520275223c153574b00df6fe4bcc9",
43
+ "docker/metadata-action": "c299e40c65443455700f0fdfc63efafe5b349051",
44
+ "docker/setup-buildx-action": "8d2750c68a42422c14e847fe6c8ac0403b4cbd6f",
45
+ "ruby/setup-ruby": "95ef2b042f9d7a56d8268cba8559e2842e2ad01b",
46
+ }
47
+ IMAGE_WORKFLOWS = {
48
+ "compute-images.yml",
49
+ "docker-image.yml",
50
+ "project-compute-image.yml",
51
+ "web-capture-image.yml",
52
+ }
53
+ PROMOTED_CORE_IMAGE_WORKFLOWS = {"compute-images.yml", "docker-image.yml", "web-capture-image.yml"}
54
+ FULLY_PINNED_WORKFLOWS = IMAGE_WORKFLOWS | {"ci.yml", "package-prepare.yml", "site-deploy.yml", "site-release.yml"}
55
+
56
+
57
+ class WorkflowLoader(yaml.SafeLoader):
58
+ pass
59
+
60
+
61
+ WorkflowLoader.yaml_implicit_resolvers = copy.deepcopy(yaml.SafeLoader.yaml_implicit_resolvers)
62
+ for initial, resolvers in list(WorkflowLoader.yaml_implicit_resolvers.items()):
63
+ WorkflowLoader.yaml_implicit_resolvers[initial] = [
64
+ (tag, regex) for tag, regex in resolvers if tag != "tag:yaml.org,2002:bool"
65
+ ]
66
+ WorkflowLoader.add_implicit_resolver(
67
+ "tag:yaml.org,2002:bool",
68
+ re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"),
69
+ list("tTfF"),
70
+ )
71
+
72
+
73
+ def _construct_mapping(loader: WorkflowLoader, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
74
+ mapping: dict[Any, Any] = {}
75
+ for key_node, value_node in node.value:
76
+ key = loader.construct_object(key_node, deep=deep)
77
+ if key in mapping:
78
+ raise ValueError(f"duplicate YAML key: {key}")
79
+ mapping[key] = loader.construct_object(value_node, deep=deep)
80
+ return mapping
81
+
82
+
83
+ WorkflowLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
84
+
85
+
86
+ def load_workflow_text(text: str) -> dict[str, Any]:
87
+ value = yaml.load(text, Loader=WorkflowLoader)
88
+ if not isinstance(value, dict):
89
+ raise ValueError("workflow root must be a mapping")
90
+ return value
91
+
92
+
93
+ def load_workflow(path: Path) -> dict[str, Any]:
94
+ return load_workflow_text(path.read_text(encoding="utf-8"))
95
+
96
+
97
+ def load_consumer_integration(root: Path) -> dict[str, Any]:
98
+ contract = json.loads((root / "src/unaltraweb_mcp/component-contract.json").read_text(encoding="utf-8"))
99
+ integration = contract.get("consumer_integration")
100
+ required = {
101
+ "schema_version",
102
+ "core_repository",
103
+ "core_sha",
104
+ "site_deploy_workflow",
105
+ "manual_pdf_image",
106
+ "vegavisuals_sha",
107
+ }
108
+ if not isinstance(integration, dict) or set(integration) != required:
109
+ raise ValueError("component contract has no exact consumer_integration object")
110
+ return integration
111
+
112
+
113
+ def load_scaffold_deploy(path: Path, integration: dict[str, Any]) -> dict[str, Any]:
114
+ replacements = {
115
+ "CORE_SHA": str(integration["core_sha"]),
116
+ "SITE_DEPLOY_WORKFLOW": str(integration["site_deploy_workflow"]),
117
+ "MANUAL_PDF_IMAGE": str(integration["manual_pdf_image"]),
118
+ "VEGAVISUALS_SHA": str(integration["vegavisuals_sha"]),
119
+ }
120
+ text = path.read_text(encoding="utf-8")
121
+ for token, value in replacements.items():
122
+ text = text.replace(f"__{token}__", value)
123
+ unresolved = sorted(set(re.findall(r"__[A-Z_]+__", text)))
124
+ if unresolved:
125
+ raise ValueError(f"unresolved scaffold tokens: {', '.join(unresolved)}")
126
+ return load_workflow_text(text)
127
+
128
+
129
+ def reusable_deploy_input_errors(workflow: dict[str, Any]) -> list[str]:
130
+ inputs = workflow.get("on", {}).get("workflow_call", {}).get("inputs", {})
131
+ expected = {
132
+ "reviewed_sha": {"required": True, "type": "string"},
133
+ "manual-pdf-image": {"required": True, "type": "string"},
134
+ "vegavisuals-sha": {"required": False, "type": "string", "default": ""},
135
+ "check-manual-pdf": {"required": False, "type": "boolean", "default": False},
136
+ "sync-manual-pdf": {"required": False, "type": "boolean", "default": True},
137
+ }
138
+ errors: list[str] = []
139
+ for name, required_shape in expected.items():
140
+ actual = inputs.get(name, {}) if isinstance(inputs, dict) else {}
141
+ if required_shape["required"] is True and actual.get("required") is not True:
142
+ errors.append(f"{name} must be required")
143
+ continue
144
+ if any(actual.get(key) != value for key, value in required_shape.items()):
145
+ errors.append(f"{name} input differs from the reviewed caller interface")
146
+ return errors
147
+
148
+
149
+ def _steps(workflow: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
150
+ result: list[tuple[str, dict[str, Any]]] = []
151
+ for job_name, job in workflow.get("jobs", {}).items():
152
+ if not isinstance(job, dict):
153
+ continue
154
+ for step in job.get("steps", []):
155
+ if isinstance(step, dict):
156
+ result.append((str(job_name), step))
157
+ return result
158
+
159
+
160
+ def _named_step(job: dict[str, Any], name: str) -> dict[str, Any]:
161
+ for step in job.get("steps", []):
162
+ if isinstance(step, dict) and step.get("name") == name:
163
+ return step
164
+ return {}
165
+
166
+
167
+ def _job_uses(workflow: dict[str, Any]) -> list[tuple[str, str]]:
168
+ result: list[tuple[str, str]] = []
169
+ for job_name, job in workflow.get("jobs", {}).items():
170
+ if isinstance(job, dict) and isinstance(job.get("uses"), str):
171
+ result.append((str(job_name), str(job["uses"])))
172
+ return result
173
+
174
+
175
+ def _run_text(job: dict[str, Any]) -> str:
176
+ return "\n".join(str(step.get("run", "")) for step in job.get("steps", []) if isinstance(step, dict))
177
+
178
+
179
+ def _needs(job: dict[str, Any]) -> set[str]:
180
+ value = job.get("needs", [])
181
+ if isinstance(value, str):
182
+ return {value}
183
+ return {str(item) for item in value} if isinstance(value, list) else set()
184
+
185
+
186
+ def _executes_candidate(step: dict[str, Any]) -> bool:
187
+ run = str(step.get("run", ""))
188
+ if str(step.get("uses", "")).startswith("docker://") or CANDIDATE_EXECUTION_COMMAND.search(run):
189
+ return True
190
+ return any(re.search(rf"(?<![\w-]){re.escape(target)}(?![\w-])", run) for target in CANDIDATE_EXECUTION_TARGETS)
191
+
192
+
193
+ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]:
194
+ errors: list[str] = []
195
+ try:
196
+ integration = load_consumer_integration(ROOT)
197
+ except Exception as exc:
198
+ errors.append(f"component contract: invalid consumer integration: {exc}")
199
+ integration = {
200
+ "core_sha": "invalid",
201
+ "site_deploy_workflow": "invalid",
202
+ "manual_pdf_image": "invalid",
203
+ "vegavisuals_sha": "invalid",
204
+ }
205
+ workflows: dict[str, dict[str, Any]] = {}
206
+ for path in sorted(root.glob("*.yml")):
207
+ try:
208
+ workflows[path.name] = load_workflow(path)
209
+ except Exception as exc:
210
+ errors.append(f"{path.name}: invalid YAML: {exc}")
211
+
212
+ for name, workflow in workflows.items():
213
+ for job_name, job in workflow.get("jobs", {}).items():
214
+ if not isinstance(job, dict) or not isinstance(job.get("env", {}), dict):
215
+ continue
216
+ for value in job.get("env", {}).values():
217
+ if isinstance(value, str) and JOB_ENV_RUNNER_CONTEXT.search(value):
218
+ errors.append(f"{name}:{job_name}: job-level env cannot use the runner context")
219
+
220
+ for job_name, step in _steps(workflow):
221
+ uses = step.get("uses")
222
+ if not isinstance(uses, str) or uses.startswith("./"):
223
+ continue
224
+ action, separator, revision = uses.rpartition("@")
225
+ if not separator:
226
+ errors.append(f"{name}:{job_name}: action has no revision: {uses}")
227
+ continue
228
+ reviewed = REVIEWED_ACTIONS.get(action)
229
+ if reviewed and revision != reviewed:
230
+ errors.append(f"{name}:{job_name}: {action} must use reviewed SHA {reviewed}")
231
+ if name in FULLY_PINNED_WORKFLOWS and not FULL_SHA.fullmatch(revision):
232
+ errors.append(f"{name}:{job_name}: action must use a full commit SHA: {uses}")
233
+ if action == "actions/checkout" and step.get("with", {}).get("persist-credentials") is not False:
234
+ errors.append(f"{name}:{job_name}: checkout must set persist-credentials: false")
235
+
236
+ for name in IMAGE_WORKFLOWS:
237
+ workflow = workflows.get(name)
238
+ if workflow is None:
239
+ errors.append(f"missing image publication workflow: {name}")
240
+ continue
241
+ jobs = workflow.get("jobs", {})
242
+ preflight = jobs.get("preflight", {})
243
+ preflight_commands = _run_text(preflight)
244
+ if name == "project-compute-image.yml":
245
+ for required_gate in ["distribution-check", "distribution-release-check"]:
246
+ if required_gate not in preflight_commands:
247
+ errors.append(f"{name}: preflight must run {required_gate} for its matching publication channel")
248
+ elif "distribution-check" not in preflight_commands:
249
+ errors.append(f"{name}: preflight must run distribution-check")
250
+ if "GITHUB_REF_TYPE" not in json.dumps(preflight) and "--validate-publish-ref" not in preflight_commands:
251
+ errors.append(f"{name}: preflight must validate the publication ref and version")
252
+ if "login-action" in "\n".join(str(step.get("uses", "")) for step in preflight.get("steps", [])):
253
+ errors.append(f"{name}: preflight must not log in to a registry")
254
+ if (
255
+ workflow.get("permissions", {}).get("packages") == "write"
256
+ or preflight.get("permissions", {}).get("packages") == "write"
257
+ ):
258
+ errors.append(f"{name}: preflight must not receive package-write permission")
259
+ if any(
260
+ isinstance(step, dict) and step.get("with", {}).get("push") is True
261
+ for step in preflight.get("steps", [])
262
+ ):
263
+ errors.append(f"{name}: preflight must not push an image")
264
+ for job_name, job in jobs.items():
265
+ if not isinstance(job, dict):
266
+ continue
267
+ push_steps = [
268
+ step for step in job.get("steps", [])
269
+ if isinstance(step, dict)
270
+ and str(step.get("uses", "")).startswith("docker/build-push-action@")
271
+ and step.get("with", {}).get("push") is True
272
+ ]
273
+ if not push_steps:
274
+ continue
275
+ if "preflight" not in _needs(job):
276
+ errors.append(f"{name}:{job_name}: publishing job must need preflight")
277
+ for step in push_steps:
278
+ settings = step.get("with", {})
279
+ if settings.get("sbom") is not True:
280
+ errors.append(f"{name}:{job_name}: publishing build must enable sbom")
281
+ if settings.get("provenance") != "mode=max":
282
+ errors.append(f"{name}:{job_name}: publishing build must use provenance: mode=max")
283
+ concurrency = workflow.get("concurrency", {})
284
+ if concurrency.get("cancel-in-progress") is not False:
285
+ errors.append(f"{name}: publication concurrency must not cancel an in-progress publish")
286
+
287
+ if name in PROMOTED_CORE_IMAGE_WORKFLOWS:
288
+ workflow_text = json.dumps(workflow)
289
+ if "sha-${{ github.sha }}" not in workflow_text:
290
+ errors.append(f"{name}: default-branch candidates must use the full source commit SHA")
291
+ if "imagetools create" not in workflow_text:
292
+ errors.append(f"{name}: release tags must promote a reviewed candidate manifest")
293
+ if "release-candidates.json" not in workflow_text:
294
+ errors.append(f"{name}: release-tag promotion must read the external candidate receipt")
295
+ if "Manifest.Digest" not in workflow_text:
296
+ errors.append(f"{name}: release-tag promotion must verify the candidate tag still resolves to the recorded digest")
297
+ for job_name, step in _steps(workflow):
298
+ if not (
299
+ str(step.get("uses", "")).startswith("docker/build-push-action@")
300
+ and step.get("with", {}).get("push") is True
301
+ ):
302
+ continue
303
+ job = jobs.get(job_name, {})
304
+ if (
305
+ str(step.get("if", "")) != "github.ref_type == 'branch'"
306
+ and str(job.get("if", "")) != "github.ref_type == 'branch'"
307
+ ):
308
+ errors.append(f"{name}:{job_name}: image builds may push only default-branch candidates")
309
+
310
+ docker_workflow = workflows.get("docker-image.yml", {})
311
+ docker_jobs = docker_workflow.get("jobs", {})
312
+ docker_preflight = docker_jobs.get("preflight", {})
313
+ docker_build = docker_jobs.get("build-candidates", {})
314
+ docker_test = docker_jobs.get("test-candidates", {})
315
+ docker_promote = docker_jobs.get("promote-candidates", {})
316
+ docker_release = docker_jobs.get("promote-release", {})
317
+ expected_docker_jobs = {
318
+ "preflight": (set(), None, {"contents": "read"}),
319
+ "build-candidates": (
320
+ {"preflight"},
321
+ "github.ref_type == 'branch'",
322
+ {"contents": "read", "packages": "write", "attestations": "write", "id-token": "write"},
323
+ ),
324
+ "test-candidates": (
325
+ {"build-candidates"},
326
+ "github.ref_type == 'branch'",
327
+ {"contents": "read", "packages": "read", "attestations": "read"},
328
+ ),
329
+ "promote-candidates": (
330
+ {"test-candidates"},
331
+ "github.ref_type == 'branch'",
332
+ {"contents": "read", "packages": "write", "attestations": "read"},
333
+ ),
334
+ "promote-release": (
335
+ {"preflight"},
336
+ "github.ref_type == 'tag'",
337
+ {"contents": "read", "packages": "write", "attestations": "read"},
338
+ ),
339
+ }
340
+ if set(docker_jobs) != set(expected_docker_jobs):
341
+ errors.append("docker-image.yml: must use only credential-separated preflight/build/test/promote/release jobs")
342
+ for job_name, (expected_needs, expected_if, expected_permissions) in expected_docker_jobs.items():
343
+ job = docker_jobs.get(job_name, {})
344
+ if (
345
+ _needs(job) != expected_needs
346
+ or job.get("if") != expected_if
347
+ or job.get("permissions") != expected_permissions
348
+ ):
349
+ errors.append(f"docker-image.yml:{job_name}: job boundary, dependency, or permissions differ from policy")
350
+
351
+ preflight_builds = [
352
+ step
353
+ for step in docker_preflight.get("steps", [])
354
+ if isinstance(step, dict)
355
+ and str(step.get("uses", "")).startswith("docker/build-push-action@")
356
+ ]
357
+ if preflight_builds or DOCKER_BUILD_COMMAND.search(_run_text(docker_preflight)):
358
+ errors.append("docker-image.yml: unprivileged preflight must not build Docker images")
359
+ if any(
360
+ "continue-on-error" in job
361
+ or any("continue-on-error" in step for step in job.get("steps", []) if isinstance(step, dict))
362
+ for job in docker_jobs.values()
363
+ if isinstance(job, dict)
364
+ ):
365
+ errors.append("docker-image.yml: publication gates must not allow job-level failure")
366
+
367
+ for job_name, job in docker_jobs.items():
368
+ if not isinstance(job, dict) or job.get("permissions", {}).get("packages") != "write":
369
+ continue
370
+ for step in job.get("steps", []):
371
+ if isinstance(step, dict) and _executes_candidate(step):
372
+ errors.append(f"docker-image.yml:{job_name}: package-write jobs must never execute candidate images")
373
+ break
374
+
375
+ for job_name in ["test-candidates", "promote-candidates", "promote-release"]:
376
+ job = docker_jobs.get(job_name, {})
377
+ if DOCKER_BUILD_COMMAND.search(_run_text(job)) or any(
378
+ isinstance(step, dict) and str(step.get("uses", "")).startswith("docker/build-push-action@")
379
+ for step in job.get("steps", [])
380
+ ):
381
+ errors.append(f"docker-image.yml:{job_name}: tested candidates must be promoted without rebuild")
382
+
383
+ expected_builds = {
384
+ "Build and publish runtime candidate": (
385
+ "runtime",
386
+ "ghcr.io/dosquartsdedocs/unaltraweb:sha-${{ github.sha }}",
387
+ ),
388
+ "Build and publish MCP candidate from runtime digest": (
389
+ "mcp",
390
+ "ghcr.io/dosquartsdedocs/unaltraweb-mcp:sha-${{ github.sha }}",
391
+ ),
392
+ "Build and publish manual PDF candidate": (
393
+ "manual",
394
+ "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf:sha-${{ github.sha }}",
395
+ ),
396
+ }
397
+ candidate_builds = [
398
+ (index, step)
399
+ for index, step in enumerate(docker_build.get("steps", []))
400
+ if isinstance(step, dict)
401
+ if str(step.get("uses", "")).startswith("docker/build-push-action@")
402
+ ]
403
+ if DOCKER_BUILD_COMMAND.search(_run_text(docker_build)):
404
+ errors.append("docker-image.yml: build-candidates may build only through the three reviewed build actions")
405
+ if [str(step.get("name")) for _, step in candidate_builds] != list(expected_builds):
406
+ errors.append(
407
+ "docker-image.yml: build-candidates must build runtime, MCP, and manual PDF exactly once in that order"
408
+ )
409
+ for _, step in candidate_builds:
410
+ name = str(step.get("name"))
411
+ settings = step.get("with", {})
412
+ expected = expected_builds.get(name)
413
+ tags = str(settings.get("tags", ""))
414
+ if re.search(r"(?:^|[:,\s])(?:main|latest)(?:$|[,\s])", tags):
415
+ errors.append(f"docker-image.yml:{name}: build-push tags must not contain broad aliases")
416
+ if expected and (
417
+ (step.get("id"), tags) != expected
418
+ or step.get("if") is not None
419
+ or settings.get("push") is not True
420
+ or settings.get("sbom") is not True
421
+ or settings.get("provenance") != "mode=max"
422
+ ):
423
+ errors.append(f"docker-image.yml:{name}: build-push tags must contain only its immutable SHA candidate")
424
+ runtime_build = _named_step(docker_build, "Build and publish runtime candidate")
425
+ mcp_build = _named_step(docker_build, "Build and publish MCP candidate from runtime digest")
426
+ manual_build = _named_step(docker_build, "Build and publish manual PDF candidate")
427
+ if mcp_build.get("with", {}).get("build-args") != (
428
+ "UNALTRAWEB_RUNTIME_IMAGE=ghcr.io/dosquartsdedocs/unaltraweb@${{ steps.runtime.outputs.digest }}"
429
+ ):
430
+ errors.append("docker-image.yml: MCP publication must use the exact runtime build digest")
431
+ for step, metadata_id in [
432
+ (runtime_build, "runtime-meta"),
433
+ (mcp_build, "mcp-meta"),
434
+ (manual_build, "manual-meta"),
435
+ ]:
436
+ if step.get("with", {}).get("labels") != f"${{{{ steps.{metadata_id}.outputs.labels }}}}":
437
+ errors.append(f"docker-image.yml:{step.get('name')}: candidate must retain OCI metadata labels")
438
+
439
+ for name in ["Runtime metadata", "MCP metadata", "Manual PDF metadata"]:
440
+ metadata = _named_step(docker_build, name)
441
+ settings = metadata.get("with", {})
442
+ if (
443
+ metadata.get("if") is not None
444
+ or settings.get("flavor") != "latest=false"
445
+ or settings.get("tags") != "type=raw,value=sha-${{ github.sha }},enable={{is_default_branch}}"
446
+ ):
447
+ errors.append(f"docker-image.yml:{name}: metadata must describe only the default-branch SHA candidate")
448
+
449
+ build_steps = [step for step in docker_build.get("steps", []) if isinstance(step, dict)]
450
+ expected_build_step_names = [
451
+ "Checkout",
452
+ "Set up Docker Buildx",
453
+ "Runtime metadata",
454
+ "MCP metadata",
455
+ "Manual PDF metadata",
456
+ "Log in to GHCR",
457
+ "Require unused candidate SHA tags",
458
+ "Build and publish runtime candidate",
459
+ "Attest runtime candidate build provenance",
460
+ "Build and publish MCP candidate from runtime digest",
461
+ "Attest MCP candidate build provenance",
462
+ "Build and publish manual PDF candidate",
463
+ "Attest manual PDF candidate build provenance",
464
+ "Record built candidate digests",
465
+ ]
466
+ if [str(step.get("name")) for step in build_steps] != expected_build_step_names:
467
+ errors.append("docker-image.yml: build-candidates step inventory differs from the reviewed boundary")
468
+ build_step_positions = {
469
+ str(step.get("name")): index for index, step in enumerate(build_steps) if step.get("name")
470
+ }
471
+ no_clobber = _named_step(docker_build, "Require unused candidate SHA tags")
472
+ expected_candidate_env = {
473
+ "MANUAL_PDF_CANDIDATE": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf:sha-${{ github.sha }}",
474
+ "MCP_CANDIDATE": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:sha-${{ github.sha }}",
475
+ "RUNTIME_CANDIDATE": "ghcr.io/dosquartsdedocs/unaltraweb:sha-${{ github.sha }}",
476
+ }
477
+ no_clobber_text = str(no_clobber.get("run", ""))
478
+ if (
479
+ no_clobber.get("if") is not None
480
+ or no_clobber.get("env") != expected_candidate_env
481
+ or "continue-on-error" in no_clobber
482
+ or any(mask in no_clobber_text for mask in ["|| true", "|| :", "set +e"])
483
+ or (
484
+ candidate_builds
485
+ and build_step_positions.get("Require unused candidate SHA tags", len(build_steps))
486
+ >= candidate_builds[0][0]
487
+ )
488
+ or not all(
489
+ marker in no_clobber_text
490
+ for marker in [
491
+ "imagetools inspect",
492
+ "candidate tag already exists",
493
+ "manifest unknown",
494
+ "not found",
495
+ 'require_absent "$RUNTIME_CANDIDATE"',
496
+ 'require_absent "$MCP_CANDIDATE"',
497
+ 'require_absent "$MANUAL_PDF_CANDIDATE"',
498
+ ]
499
+ )
500
+ ):
501
+ errors.append("docker-image.yml: branch publication must fail closed before overwriting any SHA candidate")
502
+
503
+ expected_attestations = {
504
+ "Build and publish runtime candidate": (
505
+ "Attest runtime candidate build provenance",
506
+ "ghcr.io/dosquartsdedocs/unaltraweb",
507
+ "${{ steps.runtime.outputs.digest }}",
508
+ ),
509
+ "Build and publish MCP candidate from runtime digest": (
510
+ "Attest MCP candidate build provenance",
511
+ "ghcr.io/dosquartsdedocs/unaltraweb-mcp",
512
+ "${{ steps.mcp.outputs.digest }}",
513
+ ),
514
+ "Build and publish manual PDF candidate": (
515
+ "Attest manual PDF candidate build provenance",
516
+ "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf",
517
+ "${{ steps.manual.outputs.digest }}",
518
+ ),
519
+ }
520
+ attest_action = (
521
+ "actions/attest-build-provenance@"
522
+ "4d101475d8b20a2381f78447822ac1eab6504dd8"
523
+ )
524
+ attestation_steps = [
525
+ step for step in build_steps if str(step.get("uses", "")).startswith("actions/attest-build-provenance@")
526
+ ]
527
+ if len(attestation_steps) != len(expected_attestations):
528
+ errors.append("docker-image.yml: every candidate build must have one GitHub signed provenance attestation")
529
+ for build_name, (attestation_name, subject_name, subject_digest) in expected_attestations.items():
530
+ build_position = build_step_positions.get(build_name, -2)
531
+ attestation = _named_step(docker_build, attestation_name)
532
+ expected_settings = {
533
+ "subject-name": subject_name,
534
+ "subject-digest": subject_digest,
535
+ "push-to-registry": True,
536
+ "create-storage-record": False,
537
+ }
538
+ if (
539
+ build_position < 0
540
+ or build_position + 1 >= len(build_steps)
541
+ or build_steps[build_position + 1].get("name") != attestation_name
542
+ or attestation.get("uses") != attest_action
543
+ or attestation.get("with") != expected_settings
544
+ ):
545
+ errors.append(f"docker-image.yml:{build_name}: must be immediately followed by exact signed provenance")
546
+
547
+ built = _named_step(docker_build, "Record built candidate digests")
548
+ expected_build_outputs = {
549
+ "source_commit": "${{ steps.built.outputs.source_commit }}",
550
+ "runtime_digest": "${{ steps.built.outputs.runtime_digest }}",
551
+ "mcp_digest": "${{ steps.built.outputs.mcp_digest }}",
552
+ "manual_pdf_digest": "${{ steps.built.outputs.manual_pdf_digest }}",
553
+ }
554
+ expected_built_env = {
555
+ "MANUAL_PDF_DIGEST": "${{ steps.manual.outputs.digest }}",
556
+ "MCP_DIGEST": "${{ steps.mcp.outputs.digest }}",
557
+ "RUNTIME_DIGEST": "${{ steps.runtime.outputs.digest }}",
558
+ }
559
+ built_text = str(built.get("run", ""))
560
+ built_position = build_step_positions.get("Record built candidate digests", -1)
561
+ last_attestation_position = max(
562
+ (build_step_positions.get(name, len(build_steps)) for name, _, _ in expected_attestations.values()),
563
+ default=len(build_steps),
564
+ )
565
+ if (
566
+ docker_build.get("outputs") != expected_build_outputs
567
+ or built.get("id") != "built"
568
+ or built.get("env") != expected_built_env
569
+ or built_position <= last_attestation_position
570
+ or not all(
571
+ marker in built_text
572
+ for marker in ["GITHUB_OUTPUT", "source_commit", "runtime_digest", "mcp_digest", "manual_pdf_digest"]
573
+ )
574
+ ):
575
+ errors.append("docker-image.yml: build-candidates must expose the source commit and all three exact digests")
576
+
577
+ exact_test_contract = {
578
+ "Test all Ruby files in exact runtime": (
579
+ {
580
+ "RUNTIME_IMAGE": (
581
+ "ghcr.io/dosquartsdedocs/unaltraweb@"
582
+ "${{ needs.build-candidates.outputs.runtime_digest }}"
583
+ )
584
+ },
585
+ ["docker run", '$PWD:/work:ro', "globstar", "test/**/*_test.rb", "bundle exec ruby"],
586
+ ),
587
+ "Test all manual PDF integrations in exact image": (
588
+ {
589
+ "MANUAL_PDF_IMAGE": (
590
+ "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@"
591
+ "${{ needs.build-candidates.outputs.manual_pdf_digest }}"
592
+ )
593
+ },
594
+ ["docker run", '$PWD:/work:ro', "-m unittest discover -s test/manual_pdf -p 'test_*_integration.py'"],
595
+ ),
596
+ "Check reproducible packaged Jekyll build with exact MCP": (
597
+ {
598
+ "MCP_IMAGE": (
599
+ "ghcr.io/dosquartsdedocs/unaltraweb-mcp@"
600
+ "${{ needs.build-candidates.outputs.mcp_digest }}"
601
+ )
602
+ },
603
+ ["reproducible-site-check", 'DOCKER_IMAGE="$MCP_IMAGE"'],
604
+ ),
605
+ "Smoke test exact MCP": (
606
+ {
607
+ "MCP_IMAGE": (
608
+ "ghcr.io/dosquartsdedocs/unaltraweb-mcp@"
609
+ "${{ needs.build-candidates.outputs.mcp_digest }}"
610
+ )
611
+ },
612
+ ["mcp-smoke-prebuilt", 'MCP_IMAGE="$MCP_IMAGE"'],
613
+ ),
614
+ "Build docs with exact runtime": (
615
+ {
616
+ "RUNTIME_IMAGE": (
617
+ "ghcr.io/dosquartsdedocs/unaltraweb@"
618
+ "${{ needs.build-candidates.outputs.runtime_digest }}"
619
+ )
620
+ },
621
+ ["docs-build", 'DOCKER_IMAGE="$RUNTIME_IMAGE"'],
622
+ ),
623
+ }
624
+ test_steps = [step for step in docker_test.get("steps", []) if isinstance(step, dict)]
625
+ test_step_names = [str(step.get("name")) for step in test_steps if step.get("name")]
626
+ expected_test_step_names = [
627
+ "Checkout",
628
+ "Isolate Docker credentials",
629
+ "Log in to GHCR for read-only verification",
630
+ "Verify signed provenance and exact candidate revisions",
631
+ "Remove GHCR credentials before candidate execution",
632
+ *exact_test_contract,
633
+ "Record tested candidate digests",
634
+ ]
635
+ if [str(step.get("name")) for step in test_steps] != expected_test_step_names:
636
+ errors.append("docker-image.yml: test-candidates step inventory differs from the reviewed boundary")
637
+ test_step_positions = {
638
+ str(step.get("name")): index for index, step in enumerate(test_steps) if step.get("name")
639
+ }
640
+ for name, (expected_env, markers) in exact_test_contract.items():
641
+ step = _named_step(docker_test, name)
642
+ run = str(step.get("run", ""))
643
+ if (
644
+ test_step_names.count(name) != 1
645
+ or step.get("if") is not None
646
+ or step.get("env") != expected_env
647
+ or "continue-on-error" in step
648
+ or any(mask in run for mask in ["|| true", "|| :", "set +e"])
649
+ or not all(marker in run for marker in markers)
650
+ ):
651
+ errors.append(f"docker-image.yml:{name}: must test the exact candidate build digest")
652
+
653
+ candidate_execution_steps = [
654
+ (index, step) for index, step in enumerate(test_steps) if _executes_candidate(step)
655
+ ]
656
+ if [str(step.get("name")) for _, step in candidate_execution_steps] != list(exact_test_contract):
657
+ errors.append("docker-image.yml:test-candidates: candidate execution inventory differs from the reviewed tests")
658
+ exact_test_positions = [test_step_positions[name] for name in exact_test_contract if name in test_step_positions]
659
+ first_exact_test = min(exact_test_positions) if exact_test_positions else None
660
+ last_exact_test = max(exact_test_positions) if exact_test_positions else None
661
+ if len(exact_test_positions) == len(exact_test_contract) and exact_test_positions != sorted(exact_test_positions):
662
+ errors.append("docker-image.yml: exact-image verification and tests must retain their reviewed order")
663
+
664
+ verification = _named_step(docker_test, "Verify signed provenance and exact candidate revisions")
665
+ verification_text = str(verification.get("run", ""))
666
+ expected_verification_env = {
667
+ "GH_TOKEN": "${{ github.token }}",
668
+ "MANUAL_PDF_IMAGE": (
669
+ "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@"
670
+ "${{ needs.build-candidates.outputs.manual_pdf_digest }}"
671
+ ),
672
+ "MCP_IMAGE": (
673
+ "ghcr.io/dosquartsdedocs/unaltraweb-mcp@"
674
+ "${{ needs.build-candidates.outputs.mcp_digest }}"
675
+ ),
676
+ "RUNTIME_IMAGE": (
677
+ "ghcr.io/dosquartsdedocs/unaltraweb@"
678
+ "${{ needs.build-candidates.outputs.runtime_digest }}"
679
+ ),
680
+ }
681
+ attestation_markers = [
682
+ 'gh attestation verify "oci://${image}"',
683
+ "--bundle-from-oci",
684
+ "--repo dosquartsdedocs/unaltraweb",
685
+ "--signer-workflow dosquartsdedocs/unaltraweb/.github/workflows/docker-image.yml",
686
+ '--source-digest "$GITHUB_SHA"',
687
+ 'docker pull "$image"',
688
+ "org.opencontainers.image.revision",
689
+ 'if [ "$revision" != "$GITHUB_SHA" ]',
690
+ 'verify_candidate "$RUNTIME_IMAGE"',
691
+ 'verify_candidate "$MCP_IMAGE"',
692
+ 'verify_candidate "$MANUAL_PDF_IMAGE"',
693
+ ]
694
+ if (
695
+ verification.get("env") != expected_verification_env
696
+ or any(mask in verification_text for mask in ["|| true", "|| :", "set +e"])
697
+ or not all(marker in verification_text for marker in attestation_markers)
698
+ ):
699
+ errors.append("docker-image.yml:test-candidates: exact digests require signed provenance and revision checks")
700
+
701
+ test_login = _named_step(docker_test, "Log in to GHCR for read-only verification")
702
+ expected_test_login = {
703
+ "registry": "ghcr.io",
704
+ "username": "${{ github.actor }}",
705
+ "password": "${{ secrets.GITHUB_TOKEN }}",
706
+ }
707
+ test_login_steps = [
708
+ step for step in test_steps if str(step.get("uses", "")).startswith("docker/login-action@")
709
+ ]
710
+ if (
711
+ len(test_login_steps) != 1
712
+ or test_login.get("uses")
713
+ != "docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9"
714
+ or test_login.get("with") != expected_test_login
715
+ or any(
716
+ "secrets." in json.dumps(step)
717
+ for step in test_steps
718
+ if step is not test_login
719
+ )
720
+ ):
721
+ errors.append("docker-image.yml:test-candidates: GHCR login must use only the read-scoped repository token")
722
+
723
+ isolation = _named_step(docker_test, "Isolate Docker credentials")
724
+ isolation_position = test_step_positions.get("Isolate Docker credentials", -1)
725
+ isolation_text = str(isolation.get("run", ""))
726
+ login_position = test_step_positions.get("Log in to GHCR for read-only verification", -1)
727
+ verification_position = test_step_positions.get("Verify signed provenance and exact candidate revisions", -1)
728
+ logout = _named_step(docker_test, "Remove GHCR credentials before candidate execution")
729
+ logout_position = test_step_positions.get("Remove GHCR credentials before candidate execution", -1)
730
+ logout_text = str(logout.get("run", ""))
731
+ if (
732
+ docker_test.get("env") not in (None, {})
733
+ or first_exact_test is None
734
+ or not (0 <= isolation_position < login_position < verification_position < logout_position < first_exact_test)
735
+ or isolation_text.strip()
736
+ != 'printf \'DOCKER_CONFIG=%s\\n\' "$RUNNER_TEMP/unaltraweb-test-docker" >> "$GITHUB_ENV"'
737
+ or not all(
738
+ marker in logout_text
739
+ for marker in [
740
+ "docker logout ghcr.io",
741
+ 'rm -rf -- "$DOCKER_CONFIG"',
742
+ 'mkdir -m 0700 -- "$DOCKER_CONFIG"',
743
+ 'printf \'{"auths":{}}\\n\' > "$DOCKER_CONFIG/config.json"',
744
+ ]
745
+ )
746
+ or any(
747
+ str(step.get("uses", "")).startswith("docker/login-action@")
748
+ for step in test_steps[logout_position + 1 :]
749
+ )
750
+ ):
751
+ errors.append("docker-image.yml:test-candidates: GHCR credentials must be destroyed before candidate execution")
752
+
753
+ for _, step in candidate_execution_steps:
754
+ serialized = json.dumps({"env": step.get("env", {}), "run": step.get("run", "")})
755
+ if "GH_TOKEN" in serialized or "GITHUB_TOKEN" in serialized or "secrets." in serialized:
756
+ errors.append("docker-image.yml:test-candidates: candidate execution must not receive GitHub credentials")
757
+ break
758
+
759
+ broad_promotions = [
760
+ (job_name, index, step)
761
+ for job_name, job in docker_jobs.items()
762
+ if isinstance(job, dict)
763
+ for index, step in enumerate(job.get("steps", []))
764
+ if isinstance(step, dict)
765
+ and "imagetools create" in str(step.get("run", ""))
766
+ and all(alias in str(step.get("run", "")) for alias in [':main"', ':latest"'])
767
+ ]
768
+ if len(broad_promotions) != 1 or broad_promotions[0][0] != "promote-candidates":
769
+ errors.append("docker-image.yml: main/latest aliases may be assigned only after test-candidates")
770
+ else:
771
+ promotion = broad_promotions[0][2]
772
+ promotion_text = str(promotion.get("run", ""))
773
+ expected_promotion_env = {
774
+ "MANUAL_PDF_SOURCE": (
775
+ "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@"
776
+ "${{ needs.test-candidates.outputs.manual_pdf_digest }}"
777
+ ),
778
+ "MCP_SOURCE": (
779
+ "ghcr.io/dosquartsdedocs/unaltraweb-mcp@"
780
+ "${{ needs.test-candidates.outputs.mcp_digest }}"
781
+ ),
782
+ "RUNTIME_SOURCE": (
783
+ "ghcr.io/dosquartsdedocs/unaltraweb@"
784
+ "${{ needs.test-candidates.outputs.runtime_digest }}"
785
+ ),
786
+ }
787
+ if (
788
+ promotion.get("name") != "Promote tested candidates to default-branch aliases"
789
+ or promotion.get("if") is not None
790
+ or promotion.get("env") != expected_promotion_env
791
+ or any(mask in promotion_text for mask in ["|| true", "|| :", "set +e"])
792
+ or not all(
793
+ marker in promotion_text
794
+ for marker in [
795
+ 'expected="${source##*@}"',
796
+ "--prefer-index=false",
797
+ '--tag "${repository}:sha-${GITHUB_SHA}"',
798
+ 'for alias in "sha-${GITHUB_SHA}" main latest',
799
+ "Manifest.Digest",
800
+ 'if [ "$actual" != "$expected" ]',
801
+ 'promote_aliases ghcr.io/dosquartsdedocs/unaltraweb "$RUNTIME_SOURCE"',
802
+ 'promote_aliases ghcr.io/dosquartsdedocs/unaltraweb-mcp "$MCP_SOURCE"',
803
+ 'promote_aliases ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf "$MANUAL_PDF_SOURCE"',
804
+ ]
805
+ )
806
+ ):
807
+ errors.append("docker-image.yml: broad aliases must resolve to the three tested build digests")
808
+
809
+ for job_name, step in _steps(docker_workflow):
810
+ text = json.dumps({"run": step.get("run", ""), "with": step.get("with", {})})
811
+ if any(alias in text for alias in [':main\\"', ':latest\\"']) and (
812
+ job_name != "promote-candidates"
813
+ or step.get("name") != "Promote tested candidates to default-branch aliases"
814
+ ):
815
+ errors.append("docker-image.yml: broad aliases may be assigned only by promote-candidates")
816
+ break
817
+
818
+ promote_verification = _named_step(docker_promote, "Reverify tested candidate provenance and revisions")
819
+ promote_verification_text = str(promote_verification.get("run", ""))
820
+ promote_steps = [step for step in docker_promote.get("steps", []) if isinstance(step, dict)]
821
+ expected_promote_step_names = [
822
+ "Set up Docker Buildx",
823
+ "Log in to GHCR",
824
+ "Reverify tested candidate provenance and revisions",
825
+ "Promote tested candidates to default-branch aliases",
826
+ ]
827
+ if [str(step.get("name")) for step in promote_steps] != expected_promote_step_names:
828
+ errors.append("docker-image.yml: promote-candidates step inventory differs from the reviewed boundary")
829
+ promote_positions = {
830
+ str(step.get("name")): index for index, step in enumerate(promote_steps) if step.get("name")
831
+ }
832
+ expected_promote_verification_env = {
833
+ "GH_TOKEN": "${{ github.token }}",
834
+ "MANUAL_PDF_SOURCE": (
835
+ "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@"
836
+ "${{ needs.test-candidates.outputs.manual_pdf_digest }}"
837
+ ),
838
+ "MCP_SOURCE": (
839
+ "ghcr.io/dosquartsdedocs/unaltraweb-mcp@"
840
+ "${{ needs.test-candidates.outputs.mcp_digest }}"
841
+ ),
842
+ "RUNTIME_SOURCE": (
843
+ "ghcr.io/dosquartsdedocs/unaltraweb@"
844
+ "${{ needs.test-candidates.outputs.runtime_digest }}"
845
+ ),
846
+ }
847
+ if (
848
+ promote_verification.get("env") != expected_promote_verification_env
849
+ or any(mask in promote_verification_text for mask in ["|| true", "|| :", "set +e"])
850
+ or promote_positions.get("Reverify tested candidate provenance and revisions", len(promote_steps))
851
+ >= promote_positions.get("Promote tested candidates to default-branch aliases", -1)
852
+ or not all(
853
+ marker in promote_verification_text
854
+ for marker in [
855
+ "Manifest.Digest",
856
+ 'gh attestation verify "oci://${source}"',
857
+ "--bundle-from-oci",
858
+ "--repo dosquartsdedocs/unaltraweb",
859
+ "--signer-workflow dosquartsdedocs/unaltraweb/.github/workflows/docker-image.yml",
860
+ '--source-digest "$GITHUB_SHA"',
861
+ "org.opencontainers.image.revision",
862
+ 'if [ "$revision" != "$GITHUB_SHA" ]',
863
+ ]
864
+ )
865
+ ):
866
+ errors.append("docker-image.yml: branch promotion must reverify provenance, SHA tag, and revision")
867
+
868
+ tested = _named_step(docker_test, "Record tested candidate digests")
869
+ expected_outputs = {
870
+ "runtime_digest": "${{ steps.tested.outputs.runtime_digest }}",
871
+ "mcp_digest": "${{ steps.tested.outputs.mcp_digest }}",
872
+ "manual_pdf_digest": "${{ steps.tested.outputs.manual_pdf_digest }}",
873
+ }
874
+ tested_text = str(tested.get("run", ""))
875
+ expected_tested_env = {
876
+ "MANUAL_PDF_DIGEST": "${{ needs.build-candidates.outputs.manual_pdf_digest }}",
877
+ "MCP_DIGEST": "${{ needs.build-candidates.outputs.mcp_digest }}",
878
+ "RUNTIME_DIGEST": "${{ needs.build-candidates.outputs.runtime_digest }}",
879
+ }
880
+ tested_position = test_step_positions.get("Record tested candidate digests", -1)
881
+ if (
882
+ docker_test.get("outputs") != expected_outputs
883
+ or tested.get("id") != "tested"
884
+ or tested.get("if") is not None
885
+ or tested.get("env") != expected_tested_env
886
+ or last_exact_test is None
887
+ or tested_position <= last_exact_test
888
+ or not all(
889
+ marker in tested_text
890
+ for marker in [
891
+ "GITHUB_OUTPUT",
892
+ "GITHUB_STEP_SUMMARY",
893
+ "runtime_digest",
894
+ "mcp_digest",
895
+ "manual_pdf_digest",
896
+ ]
897
+ )
898
+ ):
899
+ errors.append("docker-image.yml: test-candidates may expose digests only after every candidate test passes")
900
+
901
+ release = _named_step(docker_release, "Read release version and candidate receipt")
902
+ release_text = str(release.get("run", ""))
903
+ if (
904
+ 'receipt["components"].get(component_id)' not in release_text
905
+ or 'component["reference"]' in release_text
906
+ or "source_commit" not in release_text
907
+ ):
908
+ errors.append("docker-image.yml: release tags may promote only immutable digests recorded in the candidate receipt")
909
+ release_verification = _named_step(docker_release, "Verify recorded candidate provenance and source binding")
910
+ release_verification_text = str(release_verification.get("run", ""))
911
+ if (
912
+ "continue-on-error" in release_verification
913
+ or any(mask in release_verification_text for mask in ["|| true", "|| :", "set +e"])
914
+ or release_verification.get("env", {}).get("SOURCE_COMMIT")
915
+ != "${{ steps.release.outputs.source_commit }}"
916
+ or not all(
917
+ marker in release_verification_text
918
+ for marker in [
919
+ "Manifest.Digest",
920
+ 'gh attestation verify "oci://${source}"',
921
+ "--bundle-from-oci",
922
+ "--repo dosquartsdedocs/unaltraweb",
923
+ "--signer-workflow dosquartsdedocs/unaltraweb/.github/workflows/docker-image.yml",
924
+ '--source-digest "$SOURCE_COMMIT"',
925
+ 'docker pull "$source"',
926
+ "org.opencontainers.image.revision",
927
+ 'if [ "$revision" != "$SOURCE_COMMIT" ]',
928
+ ]
929
+ )
930
+ ):
931
+ errors.append("docker-image.yml: tag promotion must verify signed provenance bound to receipt source_commit")
932
+
933
+ release_promotions = {
934
+ "Promote reviewed runtime candidate": "runtime",
935
+ "Promote reviewed MCP candidate": "mcp",
936
+ "Promote reviewed manual PDF candidate": "manual_pdf",
937
+ }
938
+ release_steps = [step for step in docker_release.get("steps", []) if isinstance(step, dict)]
939
+ expected_release_step_names = [
940
+ "Checkout",
941
+ "Set up Docker Buildx",
942
+ "Read release version and candidate receipt",
943
+ "Log in to GHCR",
944
+ "Verify recorded candidate provenance and source binding",
945
+ *release_promotions,
946
+ ]
947
+ if [str(step.get("name")) for step in release_steps] != expected_release_step_names:
948
+ errors.append("docker-image.yml: promote-release step inventory differs from the reviewed boundary")
949
+ release_step_positions = {
950
+ str(step.get("name")): index for index, step in enumerate(release_steps) if step.get("name")
951
+ }
952
+ verification_position = release_step_positions.get(
953
+ "Verify recorded candidate provenance and source binding", len(release_steps)
954
+ )
955
+ for name, component in release_promotions.items():
956
+ step = _named_step(docker_release, name)
957
+ run = str(step.get("run", ""))
958
+ if (
959
+ step.get("if") != f"steps.release.outputs.{component} != ''"
960
+ or step.get("env", {}).get("SOURCE") != f"${{{{ steps.release.outputs.{component} }}}}"
961
+ or "imagetools create" not in run
962
+ or "--prefer-index=false" not in run
963
+ or "Manifest.Digest" not in run
964
+ or 'expected="${SOURCE##*@}"' not in run
965
+ or any(mask in run for mask in ["|| true", "|| :", "set +e"])
966
+ or release_step_positions.get(name, -1) <= verification_position
967
+ ):
968
+ errors.append(f"docker-image.yml:{name}: version aliases must use only its recorded candidate digest")
969
+
970
+ project_compute = workflows.get("project-compute-image.yml", {})
971
+ project_preflight = project_compute.get("jobs", {}).get("preflight", {})
972
+ provider_checkout = _named_step(project_preflight, "Checkout workflow release gate")
973
+ provider_settings = provider_checkout.get("with", {})
974
+ if (
975
+ provider_settings.get("repository") != "${{ job.workflow_repository }}"
976
+ or provider_settings.get("ref") != "${{ job.workflow_sha }}"
977
+ ):
978
+ errors.append("project-compute-image.yml: provider checkout must use the defining reusable workflow repository and SHA")
979
+ candidate_gate = _named_step(project_preflight, "Validate candidate core distribution")
980
+ if (
981
+ candidate_gate.get("if") != "github.ref_type == 'branch'"
982
+ or "distribution-check" not in str(candidate_gate.get("run", ""))
983
+ or "distribution-release-check" in str(candidate_gate.get("run", ""))
984
+ ):
985
+ errors.append("project-compute-image.yml: default-branch candidates must use only distribution-check")
986
+ release_gate = _named_step(project_preflight, "Require release-ready core distribution")
987
+ if (
988
+ release_gate.get("if") != "github.ref_type == 'tag'"
989
+ or "distribution-release-check" not in str(release_gate.get("run", ""))
990
+ or release_gate.get("env", {}).get("GITHUB_DEFAULT_BRANCH") != "main"
991
+ ):
992
+ errors.append("project-compute-image.yml: vX.Y.Z publication must use distribution-release-check with the core default branch")
993
+
994
+ codeql = workflows.get("codeql.yml", {})
995
+ codeql_on = codeql.get("on", {})
996
+ for event in ["push", "pull_request", "schedule"]:
997
+ if event not in codeql_on:
998
+ errors.append(f"codeql.yml: missing automatic {event} trigger")
999
+ codeql_text = json.dumps(codeql)
1000
+ for language in ["javascript-typescript", "python", "ruby"]:
1001
+ if language not in codeql_text:
1002
+ errors.append(f"codeql.yml: missing {language} analysis")
1003
+
1004
+ ci = workflows.get("ci.yml", {})
1005
+ ci_on = ci.get("on", {})
1006
+ for event in ["push", "pull_request"]:
1007
+ if event not in ci_on:
1008
+ errors.append(f"ci.yml: missing automatic {event} trigger")
1009
+ ci_text = json.dumps(ci)
1010
+ for job_name in ["python", "distribution"]:
1011
+ checkout = _named_step(ci.get("jobs", {}).get(job_name, {}), "Checkout")
1012
+ if checkout.get("with", {}).get("fetch-depth") != 0:
1013
+ errors.append(f"ci.yml:{job_name}: checkout must fetch history for reviewed core SHA validation")
1014
+ for marker in [
1015
+ "compileall", "unittest discover", "git diff --check", "distribution-check",
1016
+ "wheel-check", "gem-check", "reproducible-site-check", "mcp-smoke-prebuilt", "docs-build",
1017
+ ]:
1018
+ if marker not in ci_text:
1019
+ errors.append(f"ci.yml: missing required check {marker}")
1020
+ ci_docker = ci.get("jobs", {}).get("docker", {})
1021
+ ruby_tests = str(_named_step(ci_docker, "Test all Ruby files").get("run") or "")
1022
+ if not all(
1023
+ marker in ruby_tests
1024
+ for marker in ["$PWD:/work:ro", "globstar", "test/**/*_test.rb", "bundle exec ruby"]
1025
+ ):
1026
+ errors.append("ci.yml: Docker runtime must run every Ruby test from a read-only source mount")
1027
+ manual_pdf_tests = str(_named_step(ci_docker, "Test all manual PDF integrations").get("run") or "")
1028
+ if "-m unittest discover -s test/manual_pdf -p 'test_*_integration.py'" not in manual_pdf_tests:
1029
+ errors.append("ci.yml: manual PDF image must run every test_*_integration.py suite")
1030
+
1031
+ package = workflows.get("package-prepare.yml", {})
1032
+ package_text = json.dumps(package)
1033
+ for marker in ["distribution-check", "wheel-check", "gem-check", "sha256sum", "actions/upload-artifact@"]:
1034
+ if marker not in package_text:
1035
+ errors.append(f"package-prepare.yml: missing release preparation step {marker}")
1036
+ for forbidden in ["gem push", "twine upload", "gh release create", "docker push"]:
1037
+ if forbidden in package_text:
1038
+ errors.append(f"package-prepare.yml: forbidden publication command {forbidden}")
1039
+ if "${{ github.sha }}" not in package_text or '"overwrite": false' not in package_text:
1040
+ errors.append("package-prepare.yml: artifact must be SHA-named and immutable")
1041
+ if package.get("permissions", {}).get("contents") != "read" or len(package.get("permissions", {})) != 1:
1042
+ errors.append("package-prepare.yml: candidate preparation must have read-only repository permissions")
1043
+
1044
+ deploy = workflows.get("site-deploy.yml")
1045
+ if deploy is None:
1046
+ errors.append("missing reusable latest site workflow: site-deploy.yml")
1047
+ else:
1048
+ deploy_text = json.dumps(deploy)
1049
+ errors.extend(f"site-deploy.yml: {error}" for error in reusable_deploy_input_errors(deploy))
1050
+ for marker in [
1051
+ "refs/heads/main",
1052
+ "github.sha",
1053
+ "latest",
1054
+ "public-paths",
1055
+ "git",
1056
+ "ls-files",
1057
+ "unaltraweb-manual-pdf@sha256:",
1058
+ "MANUAL_PDF_IMAGE",
1059
+ "SOURCE_DATE_EPOCH",
1060
+ "git show --no-patch --format=%ct",
1061
+ "docker pull",
1062
+ "docker image inspect",
1063
+ "org.opencontainers.image.revision",
1064
+ ]:
1065
+ if marker not in deploy_text:
1066
+ errors.append(f"site-deploy.yml: missing latest publication gate {marker}")
1067
+ if deploy.get("permissions", {}).get("contents") != "read":
1068
+ errors.append("site-deploy.yml: reusable workflow must default to contents: read")
1069
+ deploy_build = deploy.get("jobs", {}).get("build", {})
1070
+ provenance_step = _named_step(deploy_build, "Verify manual PDF image provenance")
1071
+ if provenance_step.get("env") != {
1072
+ "WORKFLOW_REPOSITORY": "${{ job.workflow_repository }}",
1073
+ "WORKFLOW_SHA": "${{ job.workflow_sha }}",
1074
+ }:
1075
+ errors.append("site-deploy.yml: PDF worker provenance must use the defining job workflow identity")
1076
+ provenance_text = str(provenance_step.get("run") or "")
1077
+ for marker in ["MANUAL_PDF_IMAGE", "WORKFLOW_REPOSITORY", "WORKFLOW_SHA", "docker pull", "docker image inspect", "org.opencontainers.image.revision"]:
1078
+ if marker not in provenance_text:
1079
+ errors.append(f"site-deploy.yml: PDF worker provenance check missing {marker}")
1080
+ if '[[ "$image_revision" != "$WORKFLOW_SHA" ]]' not in provenance_text:
1081
+ errors.append("site-deploy.yml: PDF worker revision label must be compared with the defining workflow SHA")
1082
+ if "docker pull" in provenance_text and "docker image inspect" in provenance_text and provenance_text.index("docker pull") > provenance_text.index("docker image inspect"):
1083
+ errors.append("site-deploy.yml: PDF worker digest must be pulled before its revision label is inspected")
1084
+ if "${{ github.workflow_sha }}" in deploy_text or "${{ github.workflow_repository }}" in deploy_text:
1085
+ errors.append("site-deploy.yml: reusable provider identity must not come from the caller's github workflow context")
1086
+ build_step_names = [str(step.get("name") or "") for step in deploy_build.get("steps", []) if isinstance(step, dict)]
1087
+ if "Verify manual PDF image provenance" not in build_step_names or "Reject versioned manual publication outputs" not in build_step_names:
1088
+ errors.append("site-deploy.yml: PDF worker provenance must run before publication output processing")
1089
+ elif build_step_names.index("Verify manual PDF image provenance") > build_step_names.index("Reject versioned manual publication outputs"):
1090
+ errors.append("site-deploy.yml: PDF worker provenance must run before publication output processing")
1091
+
1092
+ stable = workflows.get("site-release.yml")
1093
+ if stable is None:
1094
+ errors.append("missing reusable stable manual workflow: site-release.yml")
1095
+ else:
1096
+ stable_text = json.dumps(stable)
1097
+ if set(stable) != {"name", "on", "permissions", "concurrency", "jobs"}:
1098
+ errors.append("site-release.yml: stable workflow contains authority outside the reviewed structure")
1099
+ stable_on = stable.get("on", {})
1100
+ if set(stable_on) != {"workflow_call"}:
1101
+ errors.append("site-release.yml: stable publication must be workflow_call-only")
1102
+ workflow_call = stable_on.get("workflow_call", {})
1103
+ if set(workflow_call) != {"inputs"}:
1104
+ errors.append("site-release.yml: stable workflow_call may only declare reviewed inputs")
1105
+ stable_inputs = workflow_call.get("inputs", {})
1106
+ expected_stable_inputs = {
1107
+ "selector",
1108
+ "reviewed_sha",
1109
+ "candidate_manifest_sha256",
1110
+ "core_sha",
1111
+ "manual_pdf_image",
1112
+ "site_build_image",
1113
+ "vegavisuals_sha",
1114
+ "ruby_version",
1115
+ "python_version",
1116
+ }
1117
+ if set(stable_inputs) != expected_stable_inputs:
1118
+ errors.append("site-release.yml: stable workflow input inventory differs from the reviewed contract")
1119
+ for input_name in ["selector", "reviewed_sha", "candidate_manifest_sha256", "core_sha", "manual_pdf_image", "site_build_image"]:
1120
+ if stable_inputs.get(input_name, {}).get("required") is not True:
1121
+ errors.append(f"site-release.yml: {input_name} must be required")
1122
+ jobs = stable.get("jobs", {})
1123
+ if set(jobs) != {"prepare", "publish"}:
1124
+ errors.append("site-release.yml: stable workflow must contain only prepare and publish jobs")
1125
+ prepare = jobs.get("prepare", {})
1126
+ publish = jobs.get("publish", {})
1127
+ if stable.get("permissions") != {"contents": "read"}:
1128
+ errors.append("site-release.yml: stable workflow must default to only contents: read")
1129
+ if prepare.get("permissions") != {"contents": "read"}:
1130
+ errors.append("site-release.yml: preparation job must have only contents: read")
1131
+ expected_concurrency = {
1132
+ "group": "stable-manual-${{ github.repository }}-${{ inputs.selector }}",
1133
+ "cancel-in-progress": False,
1134
+ }
1135
+ if stable.get("concurrency") != expected_concurrency:
1136
+ errors.append("site-release.yml: stable publication must use the reviewed non-cancelling concurrency key")
1137
+ prepare_text = json.dumps(prepare)
1138
+ for forbidden in ["gh release create", "gh release edit", "contents: write"]:
1139
+ if forbidden in prepare_text:
1140
+ errors.append(f"site-release.yml: preparation job contains publication authority: {forbidden}")
1141
+ for marker in [
1142
+ "manual-release-prepare",
1143
+ "actions/upload-artifact@",
1144
+ "SHA256SUMS",
1145
+ "reviewed_sha",
1146
+ "candidate_manifest_sha256",
1147
+ "core_sha",
1148
+ "manual_pdf_image",
1149
+ "site_build_image",
1150
+ "LOCAL_CORE",
1151
+ "SOURCE_DATE_EPOCH",
1152
+ "source_date_epoch",
1153
+ "unaltraweb-mcp@sha256:",
1154
+ "UNALTRAWEB_MCP_IMAGE_REFERENCE",
1155
+ "SITE_BUILD_PYTHON_VERSION",
1156
+ "platform.python_version()",
1157
+ "visualization-check",
1158
+ "VEGAVISUALS_PATH",
1159
+ "VEGAVISUALS_DIR",
1160
+ "--network none",
1161
+ "dst=/reviewed-core,readonly",
1162
+ ]:
1163
+ if marker not in stable_text:
1164
+ errors.append(f"site-release.yml: missing stable release evidence {marker}")
1165
+ request_step = _named_step(prepare, "Validate stable release request")
1166
+ request_text = str(request_step.get("run") or "")
1167
+ request_env = request_step.get("env", {})
1168
+ if (
1169
+ request_env.get("WORKFLOW_SHA") != "${{ job.workflow_sha }}"
1170
+ or request_env.get("WORKFLOW_REF") != "${{ job.workflow_ref }}"
1171
+ or request_env.get("WORKFLOW_REPOSITORY") != "${{ job.workflow_repository }}"
1172
+ ):
1173
+ errors.append("site-release.yml: stable request must bind core authority to the defining reusable workflow")
1174
+ for marker in ["CORE_SHA", "WORKFLOW_SHA", "WORKFLOW_REF", "WORKFLOW_REPOSITORY", "WORKFLOW_FILE_PATH"]:
1175
+ if marker not in request_text:
1176
+ errors.append(f"site-release.yml: stable request identity check missing {marker}")
1177
+
1178
+ expected_publish = {
1179
+ "needs": "prepare",
1180
+ "runs-on": "ubuntu-latest",
1181
+ "environment": "stable-release",
1182
+ "permissions": {"contents": "write"},
1183
+ "env": {
1184
+ "CORE_SHA": "${{ inputs.core_sha }}",
1185
+ "CANDIDATE_MANIFEST_SHA256": "${{ inputs.candidate_manifest_sha256 }}",
1186
+ "MANUAL_PDF_IMAGE": "${{ inputs.manual_pdf_image }}",
1187
+ "PYTHON_VERSION": "${{ inputs.python_version }}",
1188
+ "REVIEWED_SHA": "${{ inputs.reviewed_sha }}",
1189
+ "RUBY_VERSION": "${{ inputs.ruby_version }}",
1190
+ "SELECTOR": "${{ inputs.selector }}",
1191
+ "SITE_BUILD_IMAGE": "${{ inputs.site_build_image }}",
1192
+ "VEGAVISUALS_SHA": "${{ inputs.vegavisuals_sha }}",
1193
+ },
1194
+ "steps": [
1195
+ {
1196
+ "name": "Download prepared candidate",
1197
+ "uses": "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093",
1198
+ "with": {
1199
+ "name": "stable-manual-${{ inputs.selector }}-${{ inputs.reviewed_sha }}-${{ github.run_id }}-${{ github.run_attempt }}",
1200
+ "path": "release-assets",
1201
+ },
1202
+ },
1203
+ {
1204
+ "name": "Checkout reviewed release verifier",
1205
+ "uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
1206
+ "with": {
1207
+ "repository": "${{ job.workflow_repository }}",
1208
+ "ref": "${{ job.workflow_sha }}",
1209
+ "path": ".unaltraweb-release-core",
1210
+ "persist-credentials": False,
1211
+ },
1212
+ },
1213
+ {
1214
+ "name": "Setup privileged verifier Python",
1215
+ "uses": "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065",
1216
+ "with": {"python-version": "${{ inputs.python_version }}"},
1217
+ },
1218
+ {
1219
+ "name": "Publish verified stable release",
1220
+ "env": {
1221
+ "GH_TOKEN": "${{ github.token }}",
1222
+ "WORKFLOW_FILE_PATH": "${{ job.workflow_file_path }}",
1223
+ "WORKFLOW_REF": "${{ job.workflow_ref }}",
1224
+ "WORKFLOW_REPOSITORY": "${{ job.workflow_repository }}",
1225
+ "WORKFLOW_SHA": "${{ job.workflow_sha }}",
1226
+ },
1227
+ "run": "exec bash .unaltraweb-release-core/scripts/manual/publish_release.sh",
1228
+ },
1229
+ ],
1230
+ }
1231
+ if publish != expected_publish:
1232
+ errors.append("site-release.yml: privileged publication job differs from the exact reviewed structure")
1233
+
1234
+ scaffold_path = ROOT / "src/unaltraweb_mcp/scaffolds/common/.github/workflows/deploy.yml.tmpl"
1235
+ try:
1236
+ scaffold = load_scaffold_deploy(scaffold_path, integration)
1237
+ except Exception as exc:
1238
+ errors.append(f"scaffold deploy.yml: invalid YAML: {exc}")
1239
+ else:
1240
+ scaffold_on = scaffold.get("on", {})
1241
+ if set(scaffold_on) != {"workflow_dispatch"}:
1242
+ errors.append("scaffold deploy.yml: latest deployment must be workflow_dispatch-only")
1243
+ dispatch_inputs = scaffold_on.get("workflow_dispatch", {}).get("inputs", {})
1244
+ if dispatch_inputs.get("reviewed_sha", {}).get("required") is not True:
1245
+ errors.append("scaffold deploy.yml: reviewed_sha must be required")
1246
+ scaffold_text = json.dumps(scaffold)
1247
+ for marker in ["refs/heads/main", "github.sha", "reviewed_sha"]:
1248
+ if marker not in scaffold_text:
1249
+ errors.append(f"scaffold deploy.yml: missing reviewed-main gate {marker}")
1250
+ if '"contents": "write"' in scaffold_text:
1251
+ errors.append("scaffold deploy.yml: latest Pages caller must not receive contents: write")
1252
+ reusable_jobs = _job_uses(scaffold)
1253
+ if len(reusable_jobs) != 1:
1254
+ errors.append("scaffold deploy.yml: expected exactly one reusable deployment job")
1255
+ else:
1256
+ job_name, uses = reusable_jobs[0]
1257
+ action, separator, revision = uses.rpartition("@")
1258
+ if not separator or action != integration["site_deploy_workflow"] or not FULL_SHA.fullmatch(revision):
1259
+ errors.append(f"scaffold deploy.yml:{job_name}: reusable workflow must use an immutable unaltraweb SHA")
1260
+ elif revision != integration["core_sha"]:
1261
+ errors.append(f"scaffold deploy.yml:{job_name}: reusable workflow SHA has not been reviewed")
1262
+ reusable_job = scaffold.get("jobs", {}).get(job_name, {})
1263
+ if reusable_job.get("needs") != "validate":
1264
+ errors.append("scaffold deploy.yml: reusable deployment must depend on caller-side reviewed-main validation")
1265
+ settings = reusable_job.get("with", {})
1266
+ expected_settings = {
1267
+ "reviewed_sha": "${{ inputs.reviewed_sha }}",
1268
+ "manual-pdf-image": integration["manual_pdf_image"],
1269
+ "check-manual-pdf": False,
1270
+ "sync-manual-pdf": True,
1271
+ "vegavisuals-sha": integration["vegavisuals_sha"],
1272
+ }
1273
+ if settings != expected_settings:
1274
+ errors.append("scaffold deploy.yml: caller must use the exact reviewed source and PDF input shape")
1275
+ return errors
1276
+
1277
+
1278
+ def main() -> int:
1279
+ errors = validate_workflows()
1280
+ print(json.dumps({"ok": not errors, "errors": errors}, indent=2, sort_keys=True))
1281
+ return 1 if errors else 0
1282
+
1283
+
1284
+ if __name__ == "__main__":
1285
+ raise SystemExit(main())