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,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())
|