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.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/Makefile +412 -0
- data/README.md +226 -0
- data/_config.yml +842 -0
- data/_data/i18n/ca.yml +278 -0
- data/_data/i18n/en.yml +278 -0
- data/_data/i18n/es.yml +278 -0
- data/_includes/audio.liquid +30 -0
- data/_includes/bib_search.liquid +5 -0
- data/_includes/blog-list.liquid +66 -0
- data/_includes/book-citation.liquid +33 -0
- data/_includes/citation.liquid +26 -0
- data/_includes/custom_head.liquid +3 -0
- data/_includes/custom_scripts.liquid +5 -0
- data/_includes/cv/list.liquid +5 -0
- data/_includes/cv/list_groups.liquid +51 -0
- data/_includes/cv/map.liquid +29 -0
- data/_includes/cv/nested_list.liquid +16 -0
- data/_includes/cv/time_table.liquid +118 -0
- data/_includes/cv-download-card.liquid +26 -0
- data/_includes/developer-mode-switcher.liquid +24 -0
- data/_includes/disqus.liquid +13 -0
- data/_includes/distill_scripts.liquid +298 -0
- data/_includes/documentation-download-detail.liquid +76 -0
- data/_includes/documentation-downloads.liquid +71 -0
- data/_includes/documentation-profile-attributes.liquid +4 -0
- data/_includes/documentation-section-toc.liquid +9 -0
- data/_includes/documentation-sidebar.liquid +112 -0
- data/_includes/figure.liquid +87 -0
- data/_includes/footer.liquid +66 -0
- data/_includes/giscus.liquid +25 -0
- data/_includes/head.liquid +220 -0
- data/_includes/header.liquid +544 -0
- data/_includes/latest_posts.liquid +61 -0
- data/_includes/localized-date.liquid +5 -0
- data/_includes/manual-bibliography.liquid +2 -0
- data/_includes/manual-featured-readings.liquid +12 -0
- data/_includes/manual-other-readings.liquid +12 -0
- data/_includes/manual-page-toc.liquid +11 -0
- data/_includes/manual-sidebar.liquid +58 -0
- data/_includes/manual-teachers.liquid +74 -0
- data/_includes/metadata.liquid +278 -0
- data/_includes/news.liquid +48 -0
- data/_includes/newsletter.liquid +68 -0
- data/_includes/page-hero.liquid +96 -0
- data/_includes/page-link.liquid +8 -0
- data/_includes/pagination.liquid +22 -0
- data/_includes/post-reading-time.liquid +29 -0
- data/_includes/previous-next.liquid +37 -0
- data/_includes/profile-card.liquid +49 -0
- data/_includes/profile-feature-matrix.liquid +75 -0
- data/_includes/profile-highlights.liquid +122 -0
- data/_includes/project-grid.liquid +26 -0
- data/_includes/project-resource-link.liquid +45 -0
- data/_includes/project-resources.liquid +21 -0
- data/_includes/projects.liquid +38 -0
- data/_includes/projects_horizontal.liquid +37 -0
- data/_includes/publications-metrics-summary.liquid +69 -0
- data/_includes/publications-summary.liquid +127 -0
- data/_includes/reading-biblio-controls.liquid +65 -0
- data/_includes/reading-cover-card.liquid +93 -0
- data/_includes/reading-rating.liquid +11 -0
- data/_includes/recommended-readings.liquid +30 -0
- data/_includes/related_posts.liquid +39 -0
- data/_includes/repository/repo.liquid +48 -0
- data/_includes/repository/repo_trophies.liquid +44 -0
- data/_includes/repository/repo_user.liquid +35 -0
- data/_includes/resume/awards.liquid +19 -0
- data/_includes/resume/basics.liquid +28 -0
- data/_includes/resume/certificates.liquid +35 -0
- data/_includes/resume/education.liquid +54 -0
- data/_includes/resume/interests.liquid +34 -0
- data/_includes/resume/languages.liquid +31 -0
- data/_includes/resume/projects.liquid +32 -0
- data/_includes/resume/publications.liquid +28 -0
- data/_includes/resume/references.liquid +31 -0
- data/_includes/resume/skills.liquid +33 -0
- data/_includes/resume/volunteer.liquid +52 -0
- data/_includes/resume/work.liquid +52 -0
- data/_includes/scripts.liquid +364 -0
- data/_includes/search-data.js.liquid +341 -0
- data/_includes/selected_papers.liquid +3 -0
- data/_includes/t.liquid +31 -0
- data/_includes/team-grid.liquid +172 -0
- data/_includes/video.liquid +97 -0
- data/_layouts/about.liquid +93 -0
- data/_layouts/archive.liquid +45 -0
- data/_layouts/bib.liquid +677 -0
- data/_layouts/book-review.liquid +225 -0
- data/_layouts/book-shelf.liquid +66 -0
- data/_layouts/cv.liquid +127 -0
- data/_layouts/default.liquid +70 -0
- data/_layouts/distill.liquid +132 -0
- data/_layouts/documentation-download-detail.liquid +5 -0
- data/_layouts/documentation-home.liquid +100 -0
- data/_layouts/documentation-page.liquid +45 -0
- data/_layouts/manual-bib.liquid +73 -0
- data/_layouts/manual-chapter.liquid +99 -0
- data/_layouts/manual-featured-bib.liquid +100 -0
- data/_layouts/manual-home.liquid +120 -0
- data/_layouts/none.liquid +1 -0
- data/_layouts/outputs.liquid +29 -0
- data/_layouts/page.liquid +52 -0
- data/_layouts/post.liquid +95 -0
- data/_layouts/profile.liquid +36 -0
- data/_layouts/profiles.liquid +36 -0
- data/_layouts/project.liquid +7 -0
- data/_layouts/repositories.liquid +58 -0
- data/_layouts/theses.liquid +126 -0
- data/_plugins/bibliography_profiles.rb +164 -0
- data/_plugins/callouts.rb +104 -0
- data/_plugins/code_blocks.rb +277 -0
- data/_plugins/computation_figure_images.rb +266 -0
- data/_plugins/computation_sources.rb +42 -0
- data/_plugins/content_search_index.rb +210 -0
- data/_plugins/details.rb +23 -0
- data/_plugins/external-posts.rb +124 -0
- data/_plugins/figure_captions.rb +800 -0
- data/_plugins/file-exists.rb +22 -0
- data/_plugins/google-scholar-citations.rb +86 -0
- data/_plugins/hide-custom-bibtex.rb +18 -0
- data/_plugins/inspirehep-citations.rb +57 -0
- data/_plugins/localized_visual_sources.rb +145 -0
- data/_plugins/manual_release_metadata.rb +47 -0
- data/_plugins/mermaid_mmd_images.rb +233 -0
- data/_plugins/profile-pages.rb +28 -0
- data/_plugins/remove-accents.rb +32 -0
- data/_plugins/reproducible_build_time.rb +55 -0
- data/_plugins/search-data.rb +83 -0
- data/_plugins/theme-cache-bust.rb +56 -0
- data/_plugins/vega_visual_images.rb +290 -0
- data/_plugins/web_capture_images.rb +154 -0
- data/_sass/_base.scss +2133 -0
- data/_sass/_blog.scss +67 -0
- data/_sass/_callouts.scss +100 -0
- data/_sass/_custom.scss +437 -0
- data/_sass/_cv.scss +274 -0
- data/_sass/_developer.scss +97 -0
- data/_sass/_distill.scss +185 -0
- data/_sass/_documentation.scss +2220 -0
- data/_sass/_layout.scss +218 -0
- data/_sass/_manual.scss +2691 -0
- data/_sass/_profile.scss +323 -0
- data/_sass/_projects.scss +163 -0
- data/_sass/_reading.scss +465 -0
- data/_sass/_site-custom.scss +3 -0
- data/_sass/_tabs.scss +48 -0
- data/_sass/_team.scss +230 -0
- data/_sass/_themes.scss +254 -0
- data/_sass/_typograms.scss +132 -0
- data/_sass/_variables.scss +52 -0
- data/_sass/font-awesome/_animated.scss +152 -0
- data/_sass/font-awesome/_bordered-pulled.scss +20 -0
- data/_sass/font-awesome/_core.scss +49 -0
- data/_sass/font-awesome/_fixed-width.scss +7 -0
- data/_sass/font-awesome/_functions.scss +57 -0
- data/_sass/font-awesome/_icons.scss +13 -0
- data/_sass/font-awesome/_list.scss +18 -0
- data/_sass/font-awesome/_mixins.scss +65 -0
- data/_sass/font-awesome/_rotated-flipped.scss +31 -0
- data/_sass/font-awesome/_screen-reader.scss +14 -0
- data/_sass/font-awesome/_shims.scss +1578 -0
- data/_sass/font-awesome/_sizing.scss +16 -0
- data/_sass/font-awesome/_stacked.scss +32 -0
- data/_sass/font-awesome/_variables.scss +5044 -0
- data/_sass/font-awesome/brands.scss +30 -0
- data/_sass/font-awesome/fontawesome.scss +21 -0
- data/_sass/font-awesome/regular.scss +27 -0
- data/_sass/font-awesome/solid.scss +27 -0
- data/_sass/font-awesome/v4-shims.scss +11 -0
- data/_sass/tabler-icons/tabler-icons-filled.scss +1646 -0
- data/_sass/tabler-icons/tabler-icons-outline.scss +9859 -0
- data/_sass/tabler-icons/tabler-icons.scss +11463 -0
- data/_scripts/cronitor-analytics-setup.js +9 -0
- data/_scripts/giscus-setup.js +48 -0
- data/_scripts/google-analytics-setup.js +9 -0
- data/_scripts/open-panel-analytics-setup.js +14 -0
- data/_scripts/photoswipe-setup.js +11 -0
- data/assets/audio/epicaly-short-113909.mp3 +0 -0
- data/assets/bibliography/2018-12-22-distill.bib +7 -0
- data/assets/css/academicons.min.css +1 -0
- data/assets/css/bootstrap-toc.min.css +4 -0
- data/assets/css/bootstrap.min.css +7 -0
- data/assets/css/bootstrap.min.css.map +1 -0
- data/assets/css/jekyll-pygments-themes-github.css +209 -0
- data/assets/css/jekyll-pygments-themes-native.css +232 -0
- data/assets/css/jupyter-grade3.css +3758 -0
- data/assets/css/jupyter-monokai.css +3758 -0
- data/assets/css/jupyter.css +383 -0
- data/assets/css/main.scss +36 -0
- data/assets/css/mdb.min.css +48 -0
- data/assets/css/mdb.min.css.map +1 -0
- data/assets/css/scholar-icons.css +386 -0
- data/assets/fonts/academicons.ttf +0 -0
- data/assets/fonts/academicons.woff +0 -0
- data/assets/fonts/scholar-icons.ttf +0 -0
- data/assets/fonts/scholar-icons.woff +0 -0
- data/assets/fonts/tabler-icons-filled.ttf +0 -0
- data/assets/fonts/tabler-icons-filled.woff +0 -0
- data/assets/fonts/tabler-icons-filled.woff2 +0 -0
- data/assets/fonts/tabler-icons-outline.ttf +0 -0
- data/assets/fonts/tabler-icons-outline.woff +0 -0
- data/assets/fonts/tabler-icons-outline.woff2 +0 -0
- data/assets/fonts/tabler-icons.ttf +0 -0
- data/assets/fonts/tabler-icons.woff +0 -0
- data/assets/fonts/tabler-icons.woff2 +0 -0
- data/assets/html/relativity.html +15 -0
- data/assets/img/1.jpg +0 -0
- data/assets/img/10.jpg +0 -0
- data/assets/img/11.jpg +0 -0
- data/assets/img/12.jpg +0 -0
- data/assets/img/2.jpg +0 -0
- data/assets/img/3.jpg +0 -0
- data/assets/img/4.jpg +0 -0
- data/assets/img/5.jpg +0 -0
- data/assets/img/6.jpg +0 -0
- data/assets/img/7.jpg +0 -0
- data/assets/img/8.jpg +0 -0
- data/assets/img/9.jpg +0 -0
- data/assets/img/book_covers/the_godfather.jpg +0 -0
- data/assets/img/brand/dosquartsdedocs-logo-cafe.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo-footer-cafe.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo-white.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo.svg +31 -0
- data/assets/img/prof_pic.jpg +0 -0
- data/assets/img/prof_pic_color.png +0 -0
- data/assets/img/profile-placeholder.svg +8 -0
- data/assets/img/publication_preview/brownian-motion.gif +0 -0
- data/assets/img/publication_preview/goodchild1992-gis-cover.jpg +0 -0
- data/assets/img/publication_preview/gutierrez2020-jtrg-preview.jpg +0 -0
- data/assets/img/publication_preview/harvey1989-postmodernity-cover.jpg +0 -0
- data/assets/img/publication_preview/tobler1970-economic-geography-cover.jpg +0 -0
- data/assets/img/publication_preview/tuan1977-space-place-cover.jpg +0 -0
- data/assets/img/publication_preview/wave-mechanics.gif +0 -0
- data/assets/img/publication_preview/zaragozi2025-rooftop-pv-preview.jpg +0 -0
- data/assets/img/rhino.png +0 -0
- data/assets/img/team/aaron-gutierrez.png +0 -0
- data/assets/img/team/alba-font.png +0 -0
- data/assets/img/team/alba-viana.png +0 -0
- data/assets/img/team/alejandro-gonzalez.png +0 -0
- data/assets/img/team/ana-pastor.png +0 -0
- data/assets/img/team/antoni-domenech.png +0 -0
- data/assets/img/team/benito-zaragozi.png +0 -0
- data/assets/img/team/cinta-sanz.png +0 -0
- data/assets/img/team/daniel-miravet.png +0 -0
- data/assets/img/team/eugenia-altamirano.png +0 -0
- data/assets/img/team/fiammetta-brandajs.png +0 -0
- data/assets/img/team/francesc-gonzalez.png +0 -0
- data/assets/img/team/isabel-paulino.png +0 -0
- data/assets/img/team/joan-borras.png +0 -0
- data/assets/img/team/julie-wilson.png +0 -0
- data/assets/img/team/liliana-sole.png +0 -0
- data/assets/img/team/maria-casado.png +0 -0
- data/assets/img/team/maria-medina.png +0 -0
- data/assets/img/team/marta-nello.png +0 -0
- data/assets/img/team/mogrify +1 -0
- data/assets/img/team/oscar-saladie.png +0 -0
- data/assets/img/team/paolo-russo.png +0 -0
- data/assets/img/team/raquel-santos.png +0 -0
- data/assets/img/team/salvador-anton.png +0 -0
- data/assets/img/team/sunsi-huertas.png +0 -0
- data/assets/img/team/user-placeholder.png +0 -0
- data/assets/img/team/wilbert-den-hoed.png +0 -0
- data/assets/img/team/xavier-delclos.png +0 -0
- data/assets/img/template_error.png +0 -0
- data/assets/js/bibsearch.js +70 -0
- data/assets/js/bootstrap-toc.min.js +5 -0
- data/assets/js/bootstrap.bundle.min.js +7 -0
- data/assets/js/bootstrap.bundle.min.js.map +1 -0
- data/assets/js/callouts.js +115 -0
- data/assets/js/chartjs-setup.js +14 -0
- data/assets/js/common.js +117 -0
- data/assets/js/content-search-match.js +159 -0
- data/assets/js/content-search.js +441 -0
- data/assets/js/copy_code.js +60 -0
- data/assets/js/custom.js +6 -0
- data/assets/js/developer-mode.js +103 -0
- data/assets/js/diff2html-setup.js +20 -0
- data/assets/js/distillpub/overrides.js +27 -0
- data/assets/js/distillpub/template.v2.js +9616 -0
- data/assets/js/distillpub/template.v2.js.map +1 -0
- data/assets/js/distillpub/transforms.v2.js +14557 -0
- data/assets/js/distillpub/transforms.v2.js.map +1 -0
- data/assets/js/documentation.js +435 -0
- data/assets/js/echarts-setup.js +29 -0
- data/assets/js/highlight-search-term.js +110 -0
- data/assets/js/jupyter_new_tab.js +18 -0
- data/assets/js/leaflet-setup.js +22 -0
- data/assets/js/manual.js +308 -0
- data/assets/js/masonry.js +12 -0
- data/assets/js/mathjax-setup.js +46 -0
- data/assets/js/mermaid-setup.js +37 -0
- data/assets/js/newsletter.js +105 -0
- data/assets/js/no_defer.js +24 -0
- data/assets/js/plotly-setup.js +52 -0
- data/assets/js/profile-nav.js +92 -0
- data/assets/js/progress-bar.js +73 -0
- data/assets/js/pseudocode-setup.js +18 -0
- data/assets/js/search/base-styles.min.js +286 -0
- data/assets/js/search/command-score.min.js +7 -0
- data/assets/js/search/hotkeys-js/hotkeys.esm.min.js +18 -0
- data/assets/js/search/lit/decorators.min.js +7 -0
- data/assets/js/search/lit/directives/class-map.min.js +7 -0
- data/assets/js/search/lit/directives/join.min.js +7 -0
- data/assets/js/search/lit/directives/live.min.js +7 -0
- data/assets/js/search/lit/directives/ref.min.js +7 -0
- data/assets/js/search/lit/directives/repeat.min.js +7 -0
- data/assets/js/search/lit/directives/unsafe-html.min.js +7 -0
- data/assets/js/search/lit/index.min.js +7 -0
- data/assets/js/search/lit-element/lit-element.min.js +12 -0
- data/assets/js/search/lit-html/directives/class-map.min.js +22 -0
- data/assets/js/search/lit-html/directives/join.min.js +12 -0
- data/assets/js/search/lit-html/directives/live.min.js +28 -0
- data/assets/js/search/lit-html/directives/ref.min.js +32 -0
- data/assets/js/search/lit-html/directives/repeat.min.js +27 -0
- data/assets/js/search/lit-html/directives/unsafe-html.min.js +22 -0
- data/assets/js/search/lit-html/is-server.min.js +12 -0
- data/assets/js/search/lit-html/lit-html.min.js +12 -0
- data/assets/js/search/ninja-action.min.js +101 -0
- data/assets/js/search/ninja-footer.min.js +62 -0
- data/assets/js/search/ninja-header.min.js +78 -0
- data/assets/js/search/ninja-keys.min.js +39 -0
- data/assets/js/search/reactive-element/decorators/custom-element.min.js +12 -0
- data/assets/js/search/reactive-element/decorators/event-options.min.js +12 -0
- data/assets/js/search/reactive-element/decorators/property.min.js +22 -0
- data/assets/js/search/reactive-element/decorators/query-all.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-assigned-elements.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-assigned-nodes.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-async.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/state.min.js +27 -0
- data/assets/js/search/reactive-element/reactive-element.min.js +17 -0
- data/assets/js/search-setup.js +17 -0
- data/assets/js/shortcut-key.js +11 -0
- data/assets/js/tabs.min.js +5 -0
- data/assets/js/theme.js +314 -0
- data/assets/js/tooltips-setup.js +3 -0
- data/assets/js/typograms.js +1341 -0
- data/assets/js/vanilla-back-to-top.min.js +1 -0
- data/assets/js/vega-setup.js +24 -0
- data/assets/js/venobox-setup.js +5 -0
- data/assets/js/wechat.js +14 -0
- data/assets/js/zoom.js +15 -0
- data/assets/json/resume.json +98 -0
- data/assets/json/table_data.json +128 -0
- data/assets/jupyter/blog.ipynb +48 -0
- data/assets/pdf/example_pdf.pdf +0 -0
- data/assets/plotly/demo.html +72 -0
- data/assets/video/pexels-engin-akyurt-6069112-960x540-30fps.mp4 +0 -0
- data/assets/webfonts/fa-brands-400.ttf +0 -0
- data/assets/webfonts/fa-brands-400.woff2 +0 -0
- data/assets/webfonts/fa-regular-400.ttf +0 -0
- data/assets/webfonts/fa-regular-400.woff2 +0 -0
- data/assets/webfonts/fa-solid-900.ttf +0 -0
- data/assets/webfonts/fa-solid-900.woff2 +0 -0
- data/assets/webfonts/fa-v4compatibility.ttf +0 -0
- data/assets/webfonts/fa-v4compatibility.woff2 +0 -0
- data/docs/Gemfile +5 -0
- data/docs/_config.yml +83 -0
- data/docs/_documentation/en/01-quick-start.md +115 -0
- data/docs/_documentation/en/02-tools.md +146 -0
- data/docs/_documentation/en/03-usage.md +107 -0
- data/docs/_documentation/en/04-jekyll-basics.md +51 -0
- data/docs/_documentation/en/05-pages-collections.md +68 -0
- data/docs/_documentation/en/06-github-web-editing.md +92 -0
- data/docs/_documentation/en/10-profiles.md +125 -0
- data/docs/_documentation/en/11-unaltreselfie.md +39 -0
- data/docs/_documentation/en/12-unaltreprojecte.md +38 -0
- data/docs/_documentation/en/13-unaltremanual.md +237 -0
- data/docs/_documentation/en/14-unaltredocs.md +92 -0
- data/docs/_documentation/en/15-profile-checklist.md +39 -0
- data/docs/_documentation/en/20-syntax.md +353 -0
- data/docs/_documentation/en/21-themes.md +103 -0
- data/docs/_documentation/en/22-feature-reference.md +56 -0
- data/docs/_documentation/en/23-front-matter.md +51 -0
- data/docs/_documentation/en/24-data-files.md +28 -0
- data/docs/_documentation/en/30-customization.md +448 -0
- data/docs/_documentation/en/31-template.md +55 -0
- data/docs/_documentation/en/32-development.md +108 -0
- data/docs/_documentation/en/33-reusable-workflows.md +136 -0
- data/docs/_documentation/en/40-distribution.md +164 -0
- data/docs/_documentation/en/41-bibliometrics.md +137 -0
- data/docs/_documentation/en/42-docker-image.md +185 -0
- data/docs/_documentation/en/50-faq.md +33 -0
- data/docs/_pages/ca/index.md +35 -0
- data/docs/_pages/en/index.md +35 -0
- data/docs/_pages/es/index.md +35 -0
- data/docs/agents/action-prompts/00-start-site-session.txt +10 -0
- data/docs/agents/action-prompts/05-create-new-web.txt +5 -0
- data/docs/agents/action-prompts/10-content-update.txt +9 -0
- data/docs/agents/action-prompts/15-edit-default-content.txt +8 -0
- data/docs/agents/action-prompts/20-manual-teaching-materials.txt +18 -0
- data/docs/agents/action-prompts/22-manual-style-audit.txt +14 -0
- data/docs/agents/action-prompts/23-manual-structure-audit.txt +17 -0
- data/docs/agents/action-prompts/25-translation-prepublish.txt +8 -0
- data/docs/agents/action-prompts/30-project-site-update.txt +7 -0
- data/docs/agents/action-prompts/40-documentation-update.txt +7 -0
- data/docs/agents/action-prompts/50-bibliography-entry.txt +7 -0
- data/docs/agents/action-prompts/60-bibliometrics-refresh.txt +7 -0
- data/docs/agents/action-prompts/70-build-and-review.txt +12 -0
- data/docs/agents/manual-authoring-components.md +255 -0
- data/docs/agents/mcp-contract.md +162 -0
- data/docs/agents/quarto-computation-adoption.md +102 -0
- data/docs/assets/img/unaltraweb-docs-hero.svg +36 -0
- data/lib/unaltraweb/version.rb +8 -0
- data/lib/unaltraweb.rb +31 -0
- data/requirements.txt +5 -0
- data/scripts/biblio/fetch_scimago_csv.sh +126 -0
- data/scripts/biblio/metrics_common.py +154 -0
- data/scripts/biblio/metrics_merge_meta.py +73 -0
- data/scripts/biblio/metrics_update.py +826 -0
- data/scripts/computations/python/Dockerfile +37 -0
- data/scripts/computations/r/Dockerfile +31 -0
- data/scripts/computations/render.py +1267 -0
- data/scripts/cv/render_pdf_preview.sh +36 -0
- data/scripts/deploy/publish_branch.sh +80 -0
- data/scripts/generate_logo_variants.py +47 -0
- data/scripts/manual/Dockerfile +31 -0
- data/scripts/manual/build_pdf.py +1780 -0
- data/scripts/manual/filters/bibliography.lua +164 -0
- data/scripts/manual/filters/code-blocks.lua +102 -0
- data/scripts/manual/filters/figure-captions.lua +35 -0
- data/scripts/manual/publish_release.sh +281 -0
- data/scripts/manual/templates/manual.tex +494 -0
- data/scripts/manual/verify_release_assets.py +650 -0
- data/scripts/test_gem_build.py +128 -0
- data/scripts/test_reproducible_jekyll_build.py +191 -0
- data/scripts/test_wheel_install.py +282 -0
- data/scripts/unaltraweb-docker-mount.sh +36 -0
- data/scripts/unaltraweb-mcp-bootstrap.sh +105 -0
- data/scripts/unaltraweb-mcp-cleanup.sh +73 -0
- data/scripts/unaltraweb-mcp-project-id.sh +34 -0
- data/scripts/validate_distribution.py +563 -0
- data/scripts/validate_workflows.py +1285 -0
- data/scripts/web_captures/Dockerfile +31 -0
- data/scripts/web_captures/capture.mjs +157 -0
- data/scripts/web_captures/package-lock.json +56 -0
- data/scripts/web_captures/package.json +9 -0
- data/scripts/web_captures/render.py +867 -0
- data/src/unaltraweb_mcp/component-contract.json +153 -0
- data/src/unaltraweb_mcp/component-contract.schema.json +87 -0
- data/src/unaltraweb_mcp/docker_mount.py +19 -0
- 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())
|