diff --git a/.github/release-build-requirements.txt b/.github/release-build-requirements.txt new file mode 100644 index 00000000..bfe62453 --- /dev/null +++ b/.github/release-build-requirements.txt @@ -0,0 +1,136 @@ +# THE RELEASE BUILD'S OWN TOOLS, LOCKED BY HASH (#563, decision 5; review +# RV590, SHOULD 6). +# +# `.github/workflows/release.yml` installs exactly the files named here, with +# `--only-binary :all: --require-hashes`, into a virtual environment of its +# own, and builds the wheel with `python -m build --wheel --no-isolation` in +# it. So every program that touches the wheel before it is uploaded, the +# `build` frontend and the `hatchling` backend, is a file this list names by +# its digest, and so is each of hatchling's own requirements: packaging, +# pathspec, pluggy, trove-classifiers, and tomli below Python 3.11. +# `pyproject.toml` pins hatchling alone, so a build that installs it the +# ordinary way, in pip's isolated build environment, resolves those five on +# the day it runs. +# +# `hatchling==1.27.0` IS `pyproject.toml`'s OWN PIN, and must stay so: with +# `--no-isolation`, `build` refuses a backend that does not satisfy +# `[build-system] requires`, and `tests/test_release_workflow.py` holds the +# two equal. +# +# UNIVERSAL: one file for every Python from 3.9, the oldest the package +# supports, on every system, so a build on 3.9 can be held to it too. The +# release builds on Python 3.12, and the markers choose what pip installs +# there. +# +# RESOLVED, NOT WRITTEN BY HAND, with uv 0.12.18, from PyPI as it stood at +# 2026-10-01T00:00:00Z. To change it, run the command below with a newer +# date, put this header back above the output, and commit the diff as its +# own act: +# +# printf 'build\nhatchling==1.27.0\n' \ +# | uv pip compile --universal --python-version 3.9 --generate-hashes \ +# --no-header --index-url https://pypi.org/simple --only-binary :all: \ +# --exclude-newer - -o .github/release-build-requirements.txt + +build==1.4.4 ; python_full_version < '3.10' \ + --hash=sha256:8c3f48a6090b39edec1a273d2d57949aaf13723b01e02f9d518396887519f64d \ + --hash=sha256:f832ae053061f3fb524af812dc94b8b84bac6880cd587630e3b5d91a6a9c1703 +build==1.6.1 ; python_full_version >= '3.10' \ + --hash=sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53 \ + --hash=sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7 +colorama==0.4.6 ; os_name == 'nt' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via build +hatchling==1.27.0 \ + --hash=sha256:971c296d9819abb3811112fc52c7a9751c8d381898f36533bb16f9791e941fd6 \ + --hash=sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b +importlib-metadata==8.7.1 ; python_full_version < '3.10' \ + --hash=sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb \ + --hash=sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151 + # via build +importlib-metadata==9.0.1 ; python_full_version >= '3.10' and python_full_version < '3.10.2' \ + --hash=sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99 \ + --hash=sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0 + # via build +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via + # build + # hatchling +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via hatchling +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via hatchling +pyproject-hooks==1.3.3 \ + --hash=sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b \ + --hash=sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43 + # via build +tomli==2.4.1 ; python_full_version < '3.11' \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via + # build + # hatchling +trove-classifiers==2026.9.21.13 \ + --hash=sha256:0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71 \ + --hash=sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17 + # via hatchling +zipp==3.23.1 ; python_full_version < '3.10' \ + --hash=sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc \ + --hash=sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110 + # via importlib-metadata +zipp==4.1.0 ; python_full_version >= '3.10' and python_full_version < '3.10.2' \ + --hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f \ + --hash=sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602 + # via importlib-metadata diff --git a/.github/release-pipx-requirements.txt b/.github/release-pipx-requirements.txt new file mode 100644 index 00000000..bde6cdc4 --- /dev/null +++ b/.github/release-pipx-requirements.txt @@ -0,0 +1,118 @@ +# THE INSTALL CHECK'S pipx, LOCKED BY HASH (#563, decision 5). +# +# The install check in `.github/workflows/release.yml` installs exactly the +# files named here, with `--only-binary :all: --require-hashes`, on each of +# its four legs, then installs the openreposhape wheel with that pipx, as a +# person does. UNIVERSAL, for Linux, macOS and Windows: pipx 1.11.1 below +# Python 3.10, the newest pipx that installs on 3.9 (the package's oldest +# Python), and the newest pipx above it. pipx then installs pip into its own +# shared environment, which no lock here reaches; the job's `PIP_INDEX_URL` +# holds every pip it runs to PyPI. +# +# RESOLVED, NOT WRITTEN BY HAND, with uv 0.12.18, from PyPI as it stood at +# 2026-10-01T00:00:00Z. To change it, run the command below with a newer +# date, put this header back above the output, and commit the diff as its +# own act: +# +# printf 'pipx\n' \ +# | uv pip compile --universal --python-version 3.9 --generate-hashes \ +# --no-header --index-url https://pypi.org/simple --only-binary :all: \ +# --exclude-newer - -o .github/release-pipx-requirements.txt + +argcomplete==3.7.0 ; python_full_version < '3.10' \ + --hash=sha256:afde224f753f874807b1dc1414e883ab8fe0cda9c04807b6047dcb8e1ac23913 \ + --hash=sha256:d8f0f22d2a8a7caa383be1e22b6caf1ecaf0ebd10d8f83cc125e36540c95830c + # via pipx +argcomplete==3.7.2 ; python_full_version >= '3.10' \ + --hash=sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19 \ + --hash=sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9 + # via pipx +click==8.1.8 ; python_full_version < '3.10' \ + --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ + --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a + # via userpath +click==8.5.0 ; python_full_version >= '3.10' \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + # via userpath +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via + # click + # pipx +filelock==4.0.7 ; python_full_version >= '3.10' \ + --hash=sha256:a93c4d93269b339a6af4848342c7e940d0f9928ad95eff64764699e5f1bf8a6a \ + --hash=sha256:da5915714a70b55d167fdc7e251ad91302b0a36816fb574dfafae8f4f2c9bb21 + # via pipx +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via pipx +pipx==1.11.1 ; python_full_version < '3.10' \ + --hash=sha256:e57354c3308b0abb75106d3b73a0d1ffa5c25f24f29e9f4a45e575776dbab85c \ + --hash=sha256:f854fbf01c179db4331adcf6e1b95836b524100135b5d35822497a47a3857e89 +pipx==1.17.8 ; python_full_version >= '3.10' \ + --hash=sha256:19387d1ef29f9192dfb084f22a68f5f663c2e80c6c814bef193b3203a4fc2dc3 \ + --hash=sha256:90810ac9a12a5141364294a1abc24bcc8fab4572d18a6dae165895512e336966 +platformdirs==4.4.0 ; python_full_version < '3.10' \ + --hash=sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85 \ + --hash=sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf + # via pipx +platformdirs==4.12.2 ; python_full_version >= '3.10' \ + --hash=sha256:29dbf06d96c500bc6bdbce75fb0a14d63279c93b1842f97e72a135b33e856983 \ + --hash=sha256:eab5f70271a490ef74618bb314fbb86e3c7e82fa3b9c922c2ea0e0a1a155d329 + # via pipx +tomli==2.4.1 ; python_full_version < '3.11' \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via pipx +userpath==1.9.2 \ + --hash=sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d \ + --hash=sha256:6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815 + # via pipx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..f0bdedd7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,1277 @@ +name: release + +# --------------------------------------------------------------------------- +# PUBLISH THE openreposhape WHEEL: TestPyPI, AN INSTALL CHECK ON THREE +# SYSTEMS, THEN PyPI, IN ONE DISPATCHED RUN (#563, decision 5). Brett Heap +# approved the design on #563 at 2026-10-15T13:32:51Z ("approve all six for +# #563"). Adapted from opensoft/openDox-code's release.yml at c94878be: its +# dispatch, its environment and approval checks, and its digest checks on +# each side of each upload. This one builds a wheel only, from `git archive` +# of the tagged commit, and refuses a commit whose own tree has not passed +# the `tests` job this repository lands by. +# +# RELEASING TO PyPI. Brett Heap's own acts come first, once: +# - pypi.org: a pending trusted publisher for the project `openreposhape`, +# owner `opensoft`, repository `openRepoShape`, workflow `release.yml`, +# environment `pypi`; test.pypi.org: the same, environment `testpypi`. +# THE ENVIRONMENT IS REQUIRED in both, though the form calls it +# optional. A publisher that names none accepts a token from ANY job of +# a `release.yml` in this repository, so a branch's own copy of this +# file could drop `environment:` from an upload job and publish with no +# approval and no tag limit; +# - here: the environments `testpypi` and `pypi`, each with him as the +# required reviewer and a deployment limit to the tags `v*` alone; +# - recommended: a ruleset on the tags `v*` that lets only him create, +# move or delete one (see ACCEPTED LIMITS below). +# Nothing is uploaded before them: the build job refuses every dispatch +# until both environments exist and are limited so. Then, for each release: +# 1. The release commit passes `tests` ON ITSELF, in a push or dispatch +# run of tests.yml. On main, the push run that landed it does that +# when that run finished green; one that failed does not, nor one +# that a later landing cancelled (each landing cancelled the one +# before until #593). Otherwise, and always on a `release/v` +# branch, which no push run tests: BEFORE TAGGING, dispatch tests.yml +# on a branch that points at the release commit, and wait for it: +# git push origin :refs/heads/ +# gh workflow run tests.yml --ref -R opensoft/openRepoShape +# A `release/v` branch serves while it is still at that commit. +# `--ref main` tests main's CURRENT head, not an older commit. If the +# tag is pushed already and this workflow's gate refused it, dispatch +# on the tag, `gh workflow run tests.yml --ref v`, wait for +# it, then re-run the release run. (That a tag's dispatch runs at the +# tag's commit is GitHub's documented behaviour, not yet seen here; a +# branch at the commit is the proven route.) +# 2. Tag that commit, annotated, and push the tag: +# git tag -a v -m "openRepoShape v" +# git push origin refs/tags/v +# 3. Dispatch this workflow ON THE TAG, with the version: +# gh workflow run release.yml --ref v -f version= \ +# -R opensoft/openRepoShape +# 4. Approve the `testpypi` deployment. The install check then runs on +# three systems and on Python 3.9; approve the `pypi` deployment once +# it has passed. +# 5. Only then `gh release create`, so the notes never name a version +# PyPI lacks. +# A version is uploaded once. A bad upload is fixed by the next patch, never +# by moving a tag (decision 4). +# +# DISPATCH ONLY. Nothing here runs on a push, a tag or a pull request: an +# upload is an act Brett Heap approves. The version is the tag's (decision +# 4): `v1.1.0` gives `1.1.0`, which the build writes into the stamp, +# `openreposhape/_release.py`, with the tag, the commit and the digest. A +# dispatch on a tag refuses unless that tag is `v`, annotated, and +# pointing at the run's own commit, and unless that commit is on `main` or +# on the version's release branch, `release/v.` (v1.0.1 was +# cut from `release/v1.0`). +# +# A DISPATCH ON A BRANCH IS THE REHEARSAL, AND IT CANNOT DEPLOY. It builds +# and runs every check: all of the build job's, and the install check on +# all four legs, over the build's own wheel in place of TestPyPI's. Nothing +# is uploaded. Both upload jobs run only on a `v*` tag (their `if:`), but +# that is this copy's guard alone: a dispatch runs the dispatched ref's OWN +# copy of this file, and a branch can carry a copy without it. The guard +# GitHub applies to every copy is each environment's deployment limit, the +# tags `v*` alone, so no branch can deploy to either. The build job refuses +# the run when either environment lacks that limit or a required reviewer, +# and each upload job reads both again at use time. +# +# ACCEPTED LIMITS, carried over from openDox-code's release.yml, where they +# were accepted: +# - A deployment limit admits a tag by its NAME. Anyone who can push a +# `v*` tag can dispatch on it, and the run is that tag's own copy of +# this file, whatever it says. The checks here bind only where the tag +# carries the reviewed file. +# - The required reviewer's approval is therefore the last line, and lane +# sessions act as brettheap, the reviewer. An approval is a deliberate +# act of his, never a session's. +# - So a ruleset on the tags `v*` that lets only Brett Heap create, move +# or delete one is recommended, not required, and not checked here. +# +# FOUR JOBS, EACH GATED ON THE ONE BEFORE IT: +# build the refusals below; then the wheel, built twice from two +# `git archive` extractions of the commit in two directories, +# compared byte for byte, checked against the commit's own +# files, and its sha256 recorded. +# testpypi uploads those bytes to TestPyPI, in the `testpypi` +# environment, then requires TestPyPI to serve exactly that +# digest. +# install on ubuntu, macOS and Windows, and on Python 3.9: pipx installs +# TestPyPI's file, taken by the recorded digest, with its one +# dependency from PyPI and never from TestPyPI, and the commands +# it placed are run. +# pypi uploads the same bytes to PyPI, in the `pypi` environment, +# which waits for Brett Heap's approval after the install check, +# then requires PyPI to serve exactly the recorded digest. +# +# RE-RUN ONLY THE FAILED JOBS ("Re-run failed jobs"), never the whole run +# and never a second dispatch of a version already uploaded. A re-run of a +# failed job reuses the build's artifact and its recorded digest. A second +# dispatch builds again, and its wheel is the same file only while the +# locks, the runner's Python and the commit all stay the same; the index +# keeps the first file under its name for good, and the upload jobs refuse +# any other. +# +# NO PyPI TOKEN AND NO STORED SECRET, ANYWHERE. Both uploads use trusted +# publishing (OIDC): `id-token: write` is granted to the two upload jobs and +# to no other job. The one other credential is GitHub's automatic +# GITHUB_TOKEN, read-only in every job, which `gh` uses to read this +# repository's own API. +# +# EVERY ACTION IS PINNED BY COMMIT, with its release tag beside it, as +# `tests.yml` pins them; checkout and setup-python at that file's own +# commits. One limit carried over from openDox-code's workflow, where it was +# accepted: pypa/gh-action-pypi-publish is a container action whose image +# is pulled by a registry tag, so its commit pins the action's files and not +# all the code an upload runs. The digest checks before and after each +# upload bound what that could change. +# --------------------------------------------------------------------------- + +on: + workflow_dispatch: + inputs: + version: + description: >- + The version to publish, X.Y.Z. Dispatch on the tag vX.Y.Z to + publish it; a dispatch on a branch is a rehearsal and uploads + nothing. + required: true + type: string + +# Nothing by default. Each job grants itself exactly what it uses. +permissions: {} + +# One release at a time, and a running one is never cancelled halfway. +concurrency: + group: release + cancel-in-progress: false + +defaults: + run: + # `bash -eo pipefail`, on every runner, Windows' included. + shell: bash + +env: + # The input reaches every script through the environment, never pasted + # into one by an expression, so no input can become shell or Python. + VERSION: ${{ inputs.version }} + PYTHONUTF8: '1' + +jobs: + build: + name: build and verify + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read # the checkout, the tag, main and the release branch, and the forge's tree listing + actions: read # the commit's tests.yml runs and their jobs, and both environments' rules + outputs: + wheel: ${{ steps.record.outputs.wheel }} + wheel-sha256: ${{ steps.record.outputs.wheel-sha256 }} + digest: ${{ steps.digest.outputs.digest }} + steps: + # The commit alone: its tree, its time and its tag are all this job + # reads. The token is not left in the checkout's git config, where the + # build backend could find it. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + + # A FINAL RELEASE, X.Y.Z, in the form its file is named by: no leading + # zero, no pre-release, no local label. Never 0.0.0, the committed + # stamp's version, which is a build from a checkout and not a release + # (decision 4). The input is not echoed in a refusal before it is + # known to be plain. + - name: the version is a final release, and not the unstamped 0.0.0 + run: | + if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::the version input is not X.Y.Z: three numbers, no leading zero, nothing after them" + printf 'the input, quoted: %q\n' "$VERSION" + exit 1 + fi + if [ "$VERSION" = 0.0.0 ]; then + echo "::error::0.0.0 is the committed stamp's version, a build from a checkout, never a release" + exit 1 + fi + echo "version $VERSION, tag v$VERSION" + + # THE TAG, READ FROM THE FORGE AND FROM GIT. On a tag, the run must be + # dispatched on `v` itself, an ANNOTATED tag (a lightweight + # one records no tagger and no message), whose object names this + # run's commit directly. On a branch the run is the rehearsal: every + # check below still runs, and nothing can deploy. + - name: the dispatched ref is the annotated tag v at this commit, or a branch to rehearse on + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="v$VERSION" + case "$GITHUB_REF" in + "refs/tags/$tag") ;; + refs/heads/*) + echo "::notice::REHEARSAL on the branch $GITHUB_REF_NAME at $GITHUB_SHA: every check runs, the install check installs the build's own wheel, and nothing is uploaded" + exit 0 + ;; + *) + echo "::error::a release is dispatched on the tag $tag, or on a branch to rehearse, and this run names $GITHUB_REF" + exit 1 + ;; + esac + object="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" \ + --jq 'select(.object.type == "tag") | .object.sha')" || object= + if [ -z "$object" ]; then + echo "::error::the forge holds no annotated tag $tag; a release tag is made with git tag -a" + exit 1 + fi + read -r name type commit < <(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object" \ + --jq '[.tag, .object.type, .object.sha] | @tsv') || true + if [ "$name" != "$tag" ] || [ "$type" != commit ] || [ "$commit" != "$GITHUB_SHA" ]; then + echo "::error::the tag object $object is named '$name' and points at the $type $commit; a release needs $tag pointing at this run's commit $GITHUB_SHA" + exit 1 + fi + git fetch --quiet --no-tags --depth=1 origin "+refs/tags/$tag:refs/tags/$tag" + if [ "$(git cat-file -t "refs/tags/$tag")" != tag ] \ + || [ "$(git rev-parse "refs/tags/$tag")" != "$object" ] \ + || [ "$(git rev-parse "refs/tags/$tag^{commit}")" != "$GITHUB_SHA" ]; then + echo "::error::git's $tag is not the forge's annotated tag $object at $GITHUB_SHA" + exit 1 + fi + echo "$tag: annotated, tag object $object, at $GITHUB_SHA" + + # THE COMMIT IS ON main OR ON THE VERSION'S RELEASE BRANCH. A tag can be + # pushed at any commit; a release is cut from one that a reviewed branch + # holds, as `main` itself or an ancestor of it (`identical` or `ahead`). + # `release/v.` is where a patch on an older line lands + # (v1.0.1 was cut from `release/v1.0`). The compare names + # `refs/heads/`: a bare name would resolve to a tag of that name + # first. A rehearsal on a branch has no release branch to be on. + - name: the commit is on main or on the version's release branch + env: + GH_TOKEN: ${{ github.token }} + run: | + case "$GITHUB_REF" in + refs/heads/*) + echo "a rehearsal on the branch $GITHUB_REF_NAME: no release branch to be on" + exit 0 + ;; + esac + found= + for branch in main "release/v${VERSION%.*}"; do + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$GITHUB_SHA...refs/heads/$branch" \ + --jq .status 2>/dev/null)" || status= + case "$status" in + identical|ahead) + found="$branch" + echo "$GITHUB_SHA is on $branch ($status)" + break + ;; + *) echo "$GITHUB_SHA is not on $branch (compare: ${status:-no such branch})" ;; + esac + done + if [ -z "$found" ]; then + echo "::error::$GITHUB_SHA is on neither main nor release/v${VERSION%.*}; a release is cut from a commit one of them holds" + exit 1 + fi + + # THE CHECKOUT IS THIS COMMIT, AND CLEAN, before anything from it runs. + # The digest below is computed by the checkout's own + # `scripts/repo_shape.py`, and a file changed or added in the work tree + # would be code nobody reviewed. Ignored files count too. + - name: the checkout is this commit, and clean + run: | + head="$(git rev-parse HEAD)" + if [ "$head" != "$GITHUB_SHA" ]; then + echo "::error::the checkout is at $head, and this run is at $GITHUB_SHA" + exit 1 + fi + dirty="$(git status --porcelain=v1 --untracked-files=all --ignored=matching)" + if [ -n "$dirty" ]; then + echo "::error::the checkout is not clean, so the release would not be the commit's own bytes:" + printf '%s\n' "$dirty" + exit 1 + fi + echo "HEAD is $GITHUB_SHA, and the work tree holds its files and nothing else" + + # BOTH ENVIRONMENTS MUST ASK A REVIEWER AND ADMIT ONLY THE TAGS v*. + # GitHub creates an environment a job names if it does not exist yet, + # with no protection rule at all, so a dispatch made before Brett Heap + # configured them would upload with nobody approving. And a dispatch + # runs the dispatched ref's own copy of this file, so an environment + # any ref may deploy to would publish whatever a branch carries. Each + # environment must name a required reviewer, and its deployment limit + # must be exactly one rule, the tag pattern `v*`. Either gap refuses + # here, before anything is built. + - name: both environments ask a reviewer and admit only the tags v* + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 - <<'PY' + import json, os, subprocess, sys + + repo = os.environ["GITHUB_REPOSITORY"] + + + def api(path): + done = subprocess.run(["gh", "api", path], capture_output=True, text=True) + return json.loads(done.stdout) if done.returncode == 0 else None + + + problems = [] + for environment in ("testpypi", "pypi"): + settings = api(f"repos/{repo}/environments/{environment}") + if settings is None: + problems.append(f"the {environment} environment cannot be read: it does not " + "exist yet. Create it with a required reviewer and a " + "deployment limit to the tags v* first") + continue + reviewers = [entry.get("reviewer", {}).get("login") or entry.get("reviewer", {}).get("slug") + for rule in settings.get("protection_rules") or [] + if rule.get("type") == "required_reviewers" + for entry in rule.get("reviewers") or []] + policy = settings.get("deployment_branch_policy") or {} + limits = api(f"repos/{repo}/environments/{environment}/deployment-branch-policies?per_page=100") + rules = sorted((p.get("type"), p.get("name")) for p in (limits or {}).get("branch_policies") or []) + if not reviewers: + problems.append(f"the {environment} environment names no required reviewer, " + "so a deployment to it would not wait for an approval") + if policy.get("custom_branch_policies") is not True or rules != [("tag", "v*")]: + problems.append(f"the {environment} environment's deployment limit is {rules} " + f"({policy!r}), not the one rule ('tag', 'v*'): a dispatch on " + "a branch runs that branch's own copy of this file") + else: + print(f"{environment}: reviewers {reviewers}; only the tags v* can deploy") + for problem in problems: + print(f"::error::{problem}") + sys.exit(1 if problems else 0) + PY + + # THE GATE THIS REPOSITORY LANDS BY, ON THIS COMMIT'S OWN TREE. Since + # #597, `.github/workflows/tests.yml` runs `tests` on every trigger. + # It must have passed in a run that tested THIS commit: a `push` or + # `workflow_dispatch` run whose head is the commit, so the push run + # that landed a commit on main counts when it finished green (a run a + # later landing cancelled does not; each refusal names the dispatch + # that answers it, as the header's step 1 does). A + # `pull_request` run is not counted, because it reports the PR's head + # but tests a merge of it with its base, another tree. The jobs are + # read from each run itself (`actions/runs//jobs`), never from the + # commit's check runs, which anything holding an Actions token can + # create inside that run's check suite. The gate NAMES the job it + # requires and never takes the set from what a run holds: a gate that + # adapts could not notice a job gone missing. Skipped jobs are passed + # over. Of the rest, none may still be running, and the newest to + # finish must have succeeded. + - name: the commit carries a passing tests job + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 - <<'PY' + import json, os, subprocess, sys + + repo, sha = os.environ["GITHUB_REPOSITORY"], os.environ["GITHUB_SHA"] + gate, workflow = ("tests",), ".github/workflows/tests.yml" + remedy = (f"dispatch tests.yml on a branch that points at {sha} (`gh workflow run " + "tests.yml --ref `), or on the tag once it is pushed (`gh workflow " + f"run tests.yml --ref v{os.environ.get('VERSION', '')}`), wait for " + "it, then re-run this run; `--ref main` tests main's current head, not this " + "commit") + + + def rows(path, key): + done = subprocess.run(["gh", "api", "--paginate", path, "--jq", f".{key}[]"], + capture_output=True, text=True) + if done.returncode != 0: + sys.exit(f"::error::`gh api {path}` failed, so the checks on {sha} are " + f"unknown: {done.stderr.strip()}") + return [json.loads(line) for line in done.stdout.splitlines() if line.strip()] + + + runs = [run for run in rows(f"repos/{repo}/actions/runs?head_sha={sha}&per_page=100", "workflow_runs") + if run.get("path") == workflow and run.get("head_sha") == sha + and run.get("event") in ("push", "workflow_dispatch")] + checks = [job for run in runs + for job in rows(f"repos/{repo}/actions/runs/{run['id']}/jobs?filter=all&per_page=100", "jobs")] + problems = [] + for name in gate: + ran = [c for c in checks if c["name"] == name and c.get("conclusion") != "skipped"] + running = [c for c in ran if c["status"] != "completed"] + if running: + problems.append(f"`{name}` is {running[0]['status']} on {sha} " + f"({running[0]['html_url']}); dispatch again once it ends") + elif not ran: + problems.append(f"`{name}` has not run on {sha}: {remedy}") + else: + newest = max(ran, key=lambda c: c["completed_at"]) + if newest["conclusion"] != "success": + problems.append(f"`{name}`'s newest run on {sha} ended " + f"{newest['conclusion']} ({newest['html_url']}): {remedy}") + else: + print(f"{name}: success at {newest['completed_at']}, {newest['html_url']}") + for problem in problems: + print(f"::error::{problem}") + sys.exit(1 if problems else 0) + PY + + # THE DIGEST THE STAMP CARRIES, computed by the checkout's OWN + # `scripts/repo_shape.py` (`tree_digest`, `sorted-ls-tree-r-v1`), the + # digest every pin this standard writes, and required to equal the same + # definition read from the forge's own tree listing + # (`tree_digest_from_gh`). It is also the definition the package + # checks a clone against (`standard.DIGEST_DEFINITION`). + - name: the commit's tree digest is the forge's + id: digest + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 -I -B - <<'PY' + import os, sys + from pathlib import Path + + here = Path.cwd() + sys.path[:0] = [str(here / "scripts"), str(here / "src")] + import repo_shape + from openreposhape import standard + + sha, repo = os.environ["GITHUB_SHA"], os.environ["GITHUB_REPOSITORY"] + if repo_shape.TREE_DIGEST_DEFINITION != standard.DIGEST_DEFINITION: + sys.exit(f"::error::scripts/repo_shape.py digests by {repo_shape.TREE_DIGEST_DEFINITION}, " + f"and the package checks {standard.DIGEST_DEFINITION}") + try: + local = repo_shape.tree_digest(here, sha) + forge = repo_shape.tree_digest_from_gh(repo, sha) + except repo_shape.Refusal as error: + sys.exit(f"::error::{error}") + if local != forge: + sys.exit(f"::error::{sha} digests to {local} here and to {forge} from the forge's " + "tree listing; the release would not be the forge's commit") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"digest={local}\n") + print(f"{sha}: {repo_shape.TREE_DIGEST_DEFINITION} {local}, here and on the forge") + PY + + # THE RELEASE TOOLS, FROM THEIR HASH LOCK, IN A VENV OF THEIR OWN: + # the `build` frontend, the `hatchling` backend at `pyproject.toml`'s + # own pin, and every requirement of either, each by digest, wheels + # only (`.github/release-build-requirements.txt` says why). + - name: install the release tools from their hash lock + run: | + python -m venv "$RUNNER_TEMP/tools" + "$RUNNER_TEMP/tools/bin/python" -m pip install --disable-pip-version-check \ + --no-cache-dir --only-binary :all: --require-hashes \ + -r .github/release-build-requirements.txt + "$RUNNER_TEMP/tools/bin/python" -m pip list --disable-pip-version-check + python -VV + + # FROM `git archive` OF THE COMMIT, TWICE, into two directories of + # different names and depths. The wheel is built from the commit's + # own tree, never from the checkout, and two builds that share no + # directory are what can show the bytes depend on the commit alone. + - name: extract the commit twice, into two directories + run: | + tree_a="$RUNNER_TEMP/a/openRepoShape" + tree_b="$RUNNER_TEMP/b/elsewhere/v$VERSION-src" + for tree in "$tree_a" "$tree_b"; do + mkdir -p "$tree" + git archive --format=tar "$GITHUB_SHA" | tar -x -f - -C "$tree" + done + { + echo "TREE_A=$tree_a" + echo "TREE_B=$tree_b" + } >> "$GITHUB_ENV" + diff -r "$tree_a" "$tree_b" + echo "$GITHUB_SHA extracted into $tree_a and $tree_b" + + # THE STAMP, WRITTEN THE PACKAGE'S OWN WAY: the committed + # `src/openreposhape/_release.py`, each of its four assignments given + # the release's value, and nothing else changed. `pyproject.toml` + # reads VERSION from it (hatchling's version source), and + # `standard.read_stamp` reads all four and refuses a stamp that names + # part of a release. `tests/test_release_workflow.py` runs this very + # script against the committed file. + - name: write the stamp into both extractions + env: + DIGEST: ${{ steps.digest.outputs.digest }} + run: | + python3 -I - "$TREE_A/src/openreposhape/_release.py" "$TREE_B/src/openreposhape/_release.py" <<'PY' + import ast, os, re, sys + + number = r"(0|[1-9][0-9]*)" + release = {"VERSION": os.environ["VERSION"], "TAG": "v" + os.environ["VERSION"], + "COMMIT": os.environ["GITHUB_SHA"], "DIGEST": os.environ["DIGEST"]} + shapes = {"VERSION": rf"{number}\.{number}\.{number}", "TAG": rf"v{number}\.{number}\.{number}", + "COMMIT": r"[0-9a-f]{40}", "DIGEST": r"[0-9a-f]{64}"} + unstamped = {"VERSION": "0.0.0", "TAG": "", "COMMIT": "", "DIGEST": ""} + for name, value in release.items(): + if not re.fullmatch(shapes[name], value) or value == unstamped[name]: + sys.exit(f"::error::the stamp's {name} would be {value!r}, which is not a release's") + for path in sys.argv[1:]: + with open(path, encoding="utf-8", newline="") as handle: + text = handle.read() + for name, value in release.items(): + text, count = re.subn(rf'^{name} = "{re.escape(unstamped[name])}"$', + f'{name} = "{value}"', text, flags=re.M) + if count != 1: + sys.exit(f"::error::{path} assigns {name} = \"{unstamped[name]}\" {count} " + "times, not once: it is not the committed, unstamped stamp") + assigned = {node.targets[0].id: node.value.value for node in ast.parse(text).body + if isinstance(node, ast.Assign) and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Constant)} + if assigned != release: + sys.exit(f"::error::{path} assigns {assigned} after stamping, not {release}") + with open(path, "w", encoding="utf-8", newline="") as handle: + handle.write(text) + print(f"{path}: {release}") + PY + + # TWICE, AT THE COMMIT'S OWN TIME, AND THE TWO MUST BE ONE FILE: the + # bytes depend on the commit and the locked tools, not on the + # directory built in. A wheel only: one pure file, its times from + # `SOURCE_DATE_EPOCH` (an sdist can come later). `--no-isolation`, so + # the backend is the hash-locked one, and `build` refuses a backend + # that does not satisfy `[build-system] requires`. + - name: build the wheel from each extraction, and the two are one file + run: | + SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$GITHUB_SHA")" + export SOURCE_DATE_EPOCH + wheel="openreposhape-$VERSION-py3-none-any.whl" + for side in a b; do + if [ "$side" = a ]; then tree="$TREE_A"; else tree="$TREE_B"; fi + (cd "$tree" && "$RUNNER_TEMP/tools/bin/python" -m build --wheel --no-isolation \ + --outdir "$RUNNER_TEMP/dist-$side" .) + found="$(ls -A "$RUNNER_TEMP/dist-$side")" + if [ "$found" != "$wheel" ]; then + echo "::error::the build from $tree made '$found', and a release is exactly $wheel" + exit 1 + fi + done + if ! cmp "$RUNNER_TEMP/dist-a/$wheel" "$RUNNER_TEMP/dist-b/$wheel"; then + echo "::error::two builds of $GITHUB_SHA, from two extractions in two directories, are not one file: the wheel depends on where it was built, and nothing is uploaded" + exit 1 + fi + sha256sum "$RUNNER_TEMP/dist-a/$wheel" "$RUNNER_TEMP/dist-b/$wheel" + echo "SOURCE_DATE_EPOCH $SOURCE_DATE_EPOCH: two builds, one file" + + # EVERY FILE IN THE WHEEL BUT THE STAMP AND THE METADATA IS THE + # COMMIT'S OWN, BYTE FOR BYTE, read from git: each module from + # `src/openreposhape/`, and `setup-project.py` from the root. Every + # file git tracks under `src/openreposhape/` is in it, and nothing else + # is. The stamp is the committed one with its four values written, and + # the metadata names this version, `pyproject.toml`'s dependencies and + # its five commands, and carries the commit's LICENSE. + - name: the wheel holds the commit's own files, byte for byte, and the stamp + env: + DIGEST: ${{ steps.digest.outputs.digest }} + run: | + python3 -I - "$RUNNER_TEMP/dist-a/openreposhape-$VERSION-py3-none-any.whl" <<'PY' + import ast, configparser, email.parser, os, re, subprocess, sys, tomllib, zipfile + + version, sha, digest = os.environ["VERSION"], os.environ["GITHUB_SHA"], os.environ["DIGEST"] + release = {"VERSION": version, "TAG": f"v{version}", "COMMIT": sha, "DIGEST": digest} + unstamped = {"VERSION": "0.0.0", "TAG": "", "COMMIT": "", "DIGEST": ""} + info, stamp = f"openreposhape-{version}.dist-info/", "openreposhape/_release.py" + + + def committed(path): + done = subprocess.run(["git", "cat-file", "blob", f"{sha}:{path}"], capture_output=True) + return done.stdout if done.returncode == 0 else None + + + def source_of(name): + if name == "openreposhape/setup-project.py": + return "setup-project.py" + return "src/" + name if name.startswith("openreposhape/") else None + + + tracked = subprocess.run(["git", "ls-tree", "-r", "-z", "--name-only", sha, "--", + "src/openreposhape/"], capture_output=True, check=True) + wanted = {p.decode()[len("src/"):] for p in tracked.stdout.split(b"\0") if p} + wanted.add("openreposhape/setup-project.py") + problems = [] + with zipfile.ZipFile(sys.argv[1]) as wheel: + names = [n for n in wheel.namelist() if not n.endswith("/")] + if len(names) != len(set(names)): + problems.append("the wheel names one file twice") + for name in sorted(set(names)): + if name.startswith(info) or name == stamp: + continue + source = source_of(name) + if source is None or committed(source) is None: + problems.append(f"{name} is in the wheel, and the commit has no such file") + elif wheel.read(name) != committed(source): + problems.append(f"{name} in the wheel is not {source} at {sha}") + else: + print(f"{name} = {sha[:12]}:{source}") + for name in sorted(wanted - set(names)): + problems.append(f"{name} is tracked and missing from the wheel") + + base = committed("src/openreposhape/_release.py").decode().splitlines() + lines = wheel.read(stamp).decode().splitlines() + changed = {} + if len(base) != len(lines): + problems.append("the stamp is not the committed _release.py, line for line") + for old, new in zip(base, lines): + if old != new: + found = re.fullmatch(r'([A-Z]+) = "([^"]*)"', new) + name = found.group(1) if found else None + if name not in release or old != f'{name} = "{unstamped[name]}"': + problems.append(f"the stamp changes {old!r} to {new!r}") + else: + changed[name] = found.group(2) + assigned = {n.targets[0].id: n.value.value for n in ast.parse("\n".join(lines)).body + if isinstance(n, ast.Assign) and isinstance(n.targets[0], ast.Name)} + if changed != release or assigned != release: + problems.append(f"the stamp assigns {assigned}, not {release}") + print(f"{stamp}: the committed file, with {sorted(changed)} written") + + project = tomllib.loads(committed("pyproject.toml").decode())["project"] + meta = email.parser.BytesParser().parsebytes(wheel.read(info + "METADATA")) + shown = {"Name": meta["Name"], "Version": meta["Version"], + "Requires-Python": meta["Requires-Python"], + "Requires-Dist": sorted(meta.get_all("Requires-Dist") or [])} + declared = {"Name": project["name"], "Version": version, + "Requires-Python": project["requires-python"], + "Requires-Dist": sorted(project["dependencies"])} + if shown != declared: + problems.append(f"the metadata says {shown}, and pyproject.toml {declared}") + entry_points = configparser.ConfigParser(delimiters=("=",)) + entry_points.optionxform = str + entry_points.read_string(wheel.read(info + "entry_points.txt").decode()) + if entry_points.sections() != ["console_scripts"]: + problems.append(f"the wheel's entry points declare {entry_points.sections()}, " + "not ['console_scripts'] alone") + scripts = ({k: v.strip() for k, v in entry_points["console_scripts"].items()} + if entry_points.has_section("console_scripts") else {}) + if scripts != project["scripts"]: + problems.append(f"the wheel's commands are {scripts}, not {project['scripts']}") + described = sorted(n[len(info):] for n in names if n.startswith(info)) + if described != sorted(["METADATA", "WHEEL", "RECORD", "entry_points.txt", + "licenses/LICENSE"]): + problems.append(f"the wheel's {info} holds {described}, not exactly METADATA, " + "WHEEL, RECORD, entry_points.txt and licenses/LICENSE") + if wheel.read(info + "licenses/LICENSE") != committed("LICENSE"): + problems.append("the wheel's LICENSE is not the commit's") + print(f"metadata: {shown}; commands {sorted(scripts)}") + for problem in problems: + print(f"::error::{problem}") + sys.exit(1 if problems else 0) + PY + + # THE DIGEST IS RECORDED HERE, and every later job checks the file it + # holds against it: before each upload, after each upload against what + # the index serves, and before the install check runs anything. + - name: record the wheel and its sha256 + id: record + run: | + wheel="openreposhape-$VERSION-py3-none-any.whl" + { + echo "wheel=$wheel" + echo "wheel-sha256=$(sha256sum "$RUNNER_TEMP/dist-a/$wheel" | cut -d' ' -f1)" + } | tee -a "$GITHUB_OUTPUT" "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheel + path: ${{ runner.temp }}/dist-a/ + if-no-files-found: error + + testpypi: + name: publish to TestPyPI + needs: build + # ON A v* TAG ONLY. A branch is the rehearsal, and both environments + # would refuse it anyway (the header says why both guards exist). + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: testpypi + url: https://test.pypi.org/project/openreposhape/${{ inputs.version }}/ + permissions: + id-token: write # trusted publishing: the OIDC token TestPyPI accepts + actions: read # this environment's rules and this run's approvals + contents: read # the tag, read again + env: + WHEEL: ${{ needs.build.outputs.wheel }} + WHEEL_SHA256: ${{ needs.build.outputs.wheel-sha256 }} + INDEX: TestPyPI + JSON_BASE: https://test.pypi.org/pypi/openreposhape/ + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: wheel + path: dist + + - name: dist holds the wheel the build job verified, and nothing else + run: | + found="$(ls -A dist)" + if [ "$found" != "$WHEEL" ]; then + echo "::error::dist holds '$found', and the build job verified $WHEEL" + exit 1 + fi + echo "$WHEEL_SHA256 dist/$WHEEL" | sha256sum --strict -c - + + # AT USE TIME, the environment's rules again, and this run's approval. + # The build job's reading goes stale while the run waits: a rule + # removed meanwhile would let this job start with nobody approving it. + - name: this environment still asks a reviewer and admits only the tags v*, and this run's deployment to it was approved + env: + GH_TOKEN: ${{ github.token }} + ENVIRONMENT: testpypi + run: | + python3 - <<'PY' + import json, os, subprocess, sys + + repo, environment = os.environ["GITHUB_REPOSITORY"], os.environ["ENVIRONMENT"] + + + def api(path): + done = subprocess.run(["gh", "api", path], capture_output=True, text=True) + if done.returncode != 0: + sys.exit(f"::error::`gh api {path}` failed, so nothing is uploaded: {done.stderr.strip()}") + return json.loads(done.stdout) + + + settings = api(f"repos/{repo}/environments/{environment}") + reviewers = [rule for rule in settings.get("protection_rules") or [] + if rule.get("type") == "required_reviewers" and rule.get("reviewers")] + policy = settings.get("deployment_branch_policy") or {} + limits = api(f"repos/{repo}/environments/{environment}/deployment-branch-policies?per_page=100") + rules = sorted((p.get("type"), p.get("name")) for p in limits.get("branch_policies") or []) + if not reviewers: + sys.exit(f"::error::the {environment} environment names no required reviewer now") + if policy.get("custom_branch_policies") is not True or rules != [("tag", "v*")]: + sys.exit(f"::error::the {environment} environment's deployment limit is now {rules}, " + "not the one rule ('tag', 'v*'), so nothing is uploaded") + approved = [review["user"]["login"] + for review in api(f"repos/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}/approvals") + if review.get("state") == "approved" + and any(e.get("name") == environment for e in review.get("environments") or [])] + if not approved: + sys.exit(f"::error::this run holds no approval of a deployment to {environment}, " + "so nothing is uploaded") + print(f"{environment}: a reviewer, the tags v* alone, and approved by {', '.join(approved)}") + PY + + # THE TAG, AGAIN, RIGHT BEFORE THE UPLOAD. The run may have waited on + # an approval, and a tag can be moved or deleted meanwhile; an upload + # cannot be taken back. + - name: the tag v still names this commit + env: + GH_TOKEN: ${{ github.token }} + run: | + object="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/v$VERSION" \ + --jq 'select(.object.type == "tag") | .object.sha')" || object= + commit= + if [ -n "$object" ]; then + commit="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object" \ + --jq 'select(.object.type == "commit") | .object.sha')" || commit= + fi + if [ "$commit" != "$GITHUB_SHA" ]; then + echo "::error::the annotated tag v$VERSION no longer names $GITHUB_SHA (it names '$commit'), so nothing is uploaded" + exit 1 + fi + echo "v$VERSION still names $GITHUB_SHA" + + # BEFORE THE UPLOAD, NOTHING ELSE HELD. skip-existing skips a file + # name the index already holds, so the index must hold no file of + # this version but the verified wheel, at its verified digest: an + # index never replaces a file. A 404 is a version with no file yet. + - name: the index holds no file of this version but the verified wheel + run: | + python3 - <<'PY' + import json, os, sys, urllib.error, urllib.request + + index, version = os.environ["INDEX"], os.environ["VERSION"] + url = f"{os.environ['JSON_BASE']}{version}/json" + wanted = {os.environ["WHEEL"]: os.environ["WHEEL_SHA256"]} + try: + with urllib.request.urlopen(url, timeout=30) as response: + release = json.load(response) + except urllib.error.HTTPError as error: + if error.code != 404: + raise + print(f"{index} holds no file of openreposhape {version} yet") + sys.exit(0) + held = {f["filename"]: f["digests"]["sha256"] for f in release["urls"]} + other = {name: digest for name, digest in held.items() if wanted.get(name) != digest} + if other: + sys.exit(f"::error::{index} already holds {other} for openreposhape {version}, " + "which the build job did not verify, so nothing is uploaded") + yanked = sorted(f["filename"] for f in release["urls"] if f.get("yanked")) + if yanked: + sys.exit(f"::error::{index} holds {yanked} at the verified digest, but yanked; " + "un-yank it on the index, or release a new version") + print(f"{index} holds {sorted(held)}, at the verified digest; skip-existing uploads nothing new") + PY + + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + print-hash: true + + # AFTER THE UPLOAD, the index serves exactly the verified wheel, by + # digest, not yanked. Its JSON can lag an upload, so it is read up to + # 20 times, 15 seconds apart. + - name: the index serves exactly the verified wheel + run: | + python3 - <<'PY' + import json, os, sys, time, urllib.error, urllib.request + + index, version = os.environ["INDEX"], os.environ["VERSION"] + url = f"{os.environ['JSON_BASE']}{version}/json" + wanted = {os.environ["WHEEL"]: os.environ["WHEEL_SHA256"]} + served = None + for attempt in range(1, 21): + try: + with urllib.request.urlopen(url, timeout=30) as response: + release = json.load(response) + served = {f["filename"]: f["digests"]["sha256"] for f in release["urls"]} + if served == wanted: + if any(f.get("yanked") for f in release["urls"]): + sys.exit(f"::error::{index} serves the verified wheel, yanked") + break + print(f"read {attempt}: {index} serves {served} so far") + except (urllib.error.URLError, TimeoutError, ConnectionError) as error: + code = getattr(error, "code", None) + if code is not None and code != 404 and code < 500: + raise + print(f"read {attempt}: {url} did not answer yet ({error})") + time.sleep(15) + else: + sys.exit(f"::error::after 20 reads {index} serves {served}, and the build job " + f"verified {wanted}") + print(f"{index} serves exactly the verified wheel: {served}") + PY + + install: + name: install check (${{ matrix.os }}, Python ${{ matrix.python }}) + needs: [build, testpypi] + # ON A TAG, AFTER TestPyPI HOLDS THE WHEEL. On a branch, the rehearsal, + # where the testpypi job is skipped, after the build alone, over the + # build's own wheel. + if: >- + ${{ !cancelled() && needs.build.result == 'success' && + (needs.testpypi.result == 'success' || + (needs.testpypi.result == 'skipped' && !startsWith(github.ref, 'refs/tags/'))) }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + permissions: + contents: read # the checkout, for the pipx lock alone + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + python: '3.x' + - os: macos-latest + python: '3.x' + - os: windows-latest + python: '3.x' + # The oldest Python the package supports (`requires-python`). + - os: ubuntu-latest + python: '3.9' + env: + WHEEL: ${{ needs.build.outputs.wheel }} + WHEEL_SHA256: ${{ needs.build.outputs.wheel-sha256 }} + DIGEST: ${{ needs.build.outputs.digest }} + # EVERY pip THIS JOB RUNS, pipx's own included, reads PyPI and no + # other index, so the wheel's dependency, git-filter-repo, can only + # come from PyPI. TestPyPI holds git-filter-repo 2.23.0 alone, and + # anyone can upload there. + PIP_INDEX_URL: https://pypi.org/simple/ + PIP_DISABLE_PIP_VERSION_CHECK: '1' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + id: python + with: + python-version: ${{ matrix.python }} + + # FROM TestPyPI, BY THE RECORDED DIGEST: the file TestPyPI lists under + # the wheel's name, fetched from its file host, and kept only if its + # sha256 is the one the build job recorded (the step after this one). + - name: fetch the wheel from TestPyPI + if: startsWith(github.ref, 'refs/tags/') + run: | + mkdir -p "$RUNNER_TEMP/wheel" + curl --proto '=https' --tlsv1.2 -fsSL --retry 10 --retry-delay 15 --retry-all-errors \ + -o "$RUNNER_TEMP/testpypi.json" "https://test.pypi.org/pypi/openreposhape/$VERSION/json" + url="$(python - "$RUNNER_TEMP/testpypi.json" <<'PY' + import json, os, sys, urllib.parse + with open(sys.argv[1], encoding="utf-8") as handle: + files = json.load(handle)["urls"] + entries = [f for f in files if f["filename"] == os.environ["WHEEL"] + and f["digests"]["sha256"] == os.environ["WHEEL_SHA256"]] + if len(entries) != 1: + sys.exit(f"TestPyPI lists {len(entries)} {os.environ['WHEEL']} at the recorded " + f"digest, not one: {files}") + entry = entries[0] + parts = urllib.parse.urlsplit(entry["url"]) + if parts.scheme != "https" or parts.hostname != "test-files.pythonhosted.org": + sys.exit(f"TestPyPI lists the wheel at {entry['url']}, not on its own file host") + print(entry["url"]) + PY + )" + curl --proto '=https' --tlsv1.2 -fsSL --retry 5 --retry-all-errors \ + -o "$RUNNER_TEMP/wheel/$WHEEL" "$url" + echo "fetched $url" + + # THE REHEARSAL: no upload was made, so the build's own wheel. + - name: take the build's own wheel, on a branch (the rehearsal) + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: wheel + path: ${{ runner.temp }}/wheel + + - name: the wheel is the one the build job verified + run: | + python - <<'PY' + import hashlib, os, pathlib, sys + + folder = pathlib.Path(os.environ["RUNNER_TEMP"]) / "wheel" + found, wheel = sorted(p.name for p in folder.iterdir()), os.environ["WHEEL"] + if found != [wheel]: + sys.exit(f"::error::{folder} holds {found}, and the build job verified {wheel}") + digest = hashlib.sha256((folder / wheel).read_bytes()).hexdigest() + if digest != os.environ["WHEEL_SHA256"]: + sys.exit(f"::error::{wheel} has sha256 {digest}, and the build job recorded " + f"{os.environ['WHEEL_SHA256']}; nothing from it is run") + print(f"{wheel}: sha256 {digest}, the recorded digest") + PY + + # NO git-filter-repo BEFORE THE INSTALL, on PATH or reachable by git, + # so the checks below can only pass by finding the one pipx installs + # beside the package. + - name: no git-filter-repo is on PATH, or reachable by git, before the install + run: | + if command -v git-filter-repo; then + echo "::error::git-filter-repo is on PATH already, so this job cannot show the package's commands finding their own" + exit 1 + fi + if git filter-repo --version >/dev/null 2>&1; then + echo "::error::git already finds a git-filter-repo, so this job cannot show it finding the package's" + exit 1 + fi + echo "no git-filter-repo before the install" + + # pipx FROM ITS HASH LOCK, on this leg's Python, then the wheel into a + # fresh environment of pipx's, as a person installs it. Wheels only, + # so no package's setup script runs. + - name: pipx installs the wheel into a fresh environment, its dependency from PyPI + env: + PIPX_DEFAULT_PYTHON: ${{ steps.python.outputs.python-path }} + run: | + python -m pip install --only-binary :all: --require-hashes \ + -r .github/release-pipx-requirements.txt + pipx_home="$RUNNER_TEMP/pipx/home" + pipx_bin="$RUNNER_TEMP/pipx/bin" + { + echo "PIPX_HOME=$pipx_home" + echo "PIPX_BIN_DIR=$pipx_bin" + } >> "$GITHUB_ENV" + echo "$pipx_bin" >> "$GITHUB_PATH" + export PIPX_HOME="$pipx_home" PIPX_BIN_DIR="$pipx_bin" + python -m pipx --version + python -m pipx install --pip-args=--only-binary=:all: "$RUNNER_TEMP/wheel/$WHEEL" + + # THE FIVE COMMANDS, AND NO OTHER, WITH THEIR CAPITALS. pipx exposes + # the package's own commands and not its dependency's; `openRepoShape` + # keeps its capitals on macOS and Windows, whose disks fold case + # (`.exe` on Windows, where pipx copies native launchers). + - name: pipx placed the five commands, openRepoShape with its capitals + run: | + python - <<'PY' + import json, os, shutil, subprocess, sys + + names = sorted(["openRepoShape", "scaffold-project", "adopt-project", "update-shape", + "shape-doctor"]) + suffix = ".exe" if os.name == "nt" else "" + placed = sorted(os.listdir(os.environ["PIPX_BIN_DIR"])) + if placed != sorted(name + suffix for name in names): + sys.exit(f"::error::pipx placed {placed}, not the five commands {names}") + listing = json.loads(subprocess.run([sys.executable, "-m", "pipx", "list", "--json"], + capture_output=True, text=True, check=True).stdout) + package = listing["venvs"]["openreposhape"]["metadata"]["main_package"] + apps = sorted(a[: -len(suffix)] if suffix and a.endswith(suffix) else a + for a in package["apps"]) + if apps != names or package["package_version"] != os.environ["VERSION"]: + sys.exit(f"::error::pipx records openreposhape {package['package_version']} with " + f"the commands {apps}") + found = shutil.which("openRepoShape") + if not found or not os.path.samefile(os.path.dirname(found), os.environ["PIPX_BIN_DIR"]): + sys.exit(f"::error::openRepoShape on PATH is {found!r}, not the one pipx placed") + print(f"pipx placed {placed}, for openreposhape {package['package_version']}; " + f"openRepoShape on PATH is {found}") + PY + + - name: openRepoShape --version names the commit + run: | + cd "$RUNNER_TEMP" + openRepoShape --version | tee version.txt + python - version.txt <<'PY' + import os, re, sys + + with open(sys.argv[1], encoding="utf-8") as handle: + lines = handle.read().splitlines() + version, sha, digest = os.environ["VERSION"], os.environ["GITHUB_SHA"], os.environ["DIGEST"] + wanted = [rf"openRepoShape {re.escape(version)} \(.* @ {sha}\)", rf"\s+tag\s+v{re.escape(version)}", + rf"\s+commit\s+{sha}", rf"\s+digest\s+{digest}( .*)?"] + missing = [w for w in wanted if not any(re.fullmatch(w, line) for line in lines)] + if missing or not re.fullmatch(wanted[0], lines[0]): + sys.exit(f"::error::openRepoShape --version does not name version {version}, tag " + f"v{version}, commit {sha} and digest {digest}: {missing}") + print(f"--version names {version}, v{version}, {sha} and {digest}") + PY + + # The preflight `setup-project.py` runs, from the copy the wheel + # carries, so it needs no clone. `--local-remote-dir` keeps `gh` out of + # it: this runner has no `gh` login to offer. + - name: openRepoShape --preflight runs + run: | + cd "$RUNNER_TEMP" + openRepoShape --preflight --local-remote-dir "$RUNNER_TEMP/no-remotes" + + # THE FIRST RUN THAT CLONES: the standard, from github.com, at the + # stamped commit, checked against the stamped digest by the clone's + # own `scripts/repo_shape.py`. An empty repository is NOT A SHAPE ROOT + # (exit 2), and the machine row must find the git-filter-repo pipx put + # in the package's environment, through the PATH the package gives + # its children. + - name: openRepoShape --doctor over an empty repository finds git-filter-repo in its machine row + run: | + cd "$RUNNER_TEMP" + git init -q "$RUNNER_TEMP/empty" + status=0 + openRepoShape --doctor "$RUNNER_TEMP/empty" --json > doctor.json || status=$? + if [ "$status" -ne 2 ]; then + echo "::error::openRepoShape --doctor over an empty repository exited $status, not 2 (NOT A SHAPE ROOT)" + cat doctor.json + exit 1 + fi + python - doctor.json <<'PY' + import json, os, pathlib, sys + + venv = pathlib.Path(os.environ["PIPX_HOME"]) / "venvs" / "openreposhape" + scripts = venv / ("Scripts" if os.name == "nt" else "bin") + with open(sys.argv[1], encoding="utf-8") as handle: + report = json.load(handle) + [machine] = [row for row in report["rows"] if row["id"] == "machine"] + found = (machine.get("detail") or {}).get("git-filter-repo") + print(f"verdict {report['verdict']}; machine row: git-filter-repo at {found}") + if not found or not os.path.samefile(os.path.dirname(found), scripts): + sys.exit(f"::error::the doctor's machine row found git-filter-repo at {found!r}, " + f"not in the package's environment, {scripts}") + PY + + # GIT ITSELF FINDS IT TOO: `git filter-repo`, which adopt runs, with + # the PATH the package's own `standard.child_environment` gives every + # child. On Windows this is git for Windows finding + # `git-filter-repo.exe` in the pipx environment's `Scripts`. + - name: git finds git-filter-repo in the package's environment, on the PATH the package gives its children + run: | + cd "$RUNNER_TEMP" + venv="$PIPX_HOME/venvs/openreposhape" + venv_python="$venv/bin/python" + if [ ! -f "$venv_python" ]; then venv_python="$venv/Scripts/python.exe"; fi + "$venv_python" -I - <<'PY' + import os, shutil, subprocess, sys, sysconfig + from openreposhape import standard + + env, scripts = standard.child_environment(), sysconfig.get_path("scripts") + found = shutil.which("git-filter-repo", path=env["PATH"]) + if not found or not os.path.samefile(os.path.dirname(found), scripts): + sys.exit(f"::error::on the children's PATH git-filter-repo is {found!r}, not the one in {scripts}") + done = subprocess.run(["git", "filter-repo", "--version"], env=env, + capture_output=True, text=True) + if done.returncode != 0: + sys.exit(f"::error::git filter-repo --version exited {done.returncode}: {done.stderr.strip()}") + print(f"git filter-repo --version: {done.stdout.strip()}, from {found}") + PY + + - name: the four tool commands run from the clone + run: | + cd "$RUNNER_TEMP" + for tool in scaffold-project adopt-project update-shape shape-doctor; do + "$tool" --help > "help-$tool.txt" + echo "$tool --help: $(head -n 1 "help-$tool.txt")" + done + + # NATIVE WINDOWS, AS A PERSON THERE TYPES IT: PowerShell finds + # `openRepoShape.exe` on PATH by the name with its capitals. + - name: PowerShell runs openRepoShape by its name (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Set-Location $env:RUNNER_TEMP + $command = Get-Command openRepoShape + "openRepoShape is $($command.Source)" + $out = & openRepoShape --version + if ($LASTEXITCODE -ne 0) { throw "openRepoShape --version exited $LASTEXITCODE" } + $out + if (-not (($out -join "`n") -match [regex]::Escape($env:GITHUB_SHA))) { + throw "openRepoShape --version does not name $env:GITHUB_SHA" + } + + pypi: + name: publish to PyPI + needs: [build, testpypi, install] + # ON A v* TAG ONLY, and only after TestPyPI and every leg of the install + # check passed: with no status function in it, `if:` also requires + # every job above to have succeeded. + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: pypi + url: https://pypi.org/project/openreposhape/${{ inputs.version }}/ + permissions: + id-token: write # trusted publishing: the OIDC token PyPI accepts + actions: read # this environment's rules and this run's approvals + contents: read # the tag, read again + env: + WHEEL: ${{ needs.build.outputs.wheel }} + WHEEL_SHA256: ${{ needs.build.outputs.wheel-sha256 }} + INDEX: PyPI + JSON_BASE: https://pypi.org/pypi/openreposhape/ + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: wheel + path: dist + + - name: dist holds the wheel the build job verified, and nothing else + run: | + found="$(ls -A dist)" + if [ "$found" != "$WHEEL" ]; then + echo "::error::dist holds '$found', and the build job verified $WHEEL" + exit 1 + fi + echo "$WHEEL_SHA256 dist/$WHEEL" | sha256sum --strict -c - + + - name: this environment still asks a reviewer and admits only the tags v*, and this run's deployment to it was approved + env: + GH_TOKEN: ${{ github.token }} + ENVIRONMENT: pypi + run: | + python3 - <<'PY' + import json, os, subprocess, sys + + repo, environment = os.environ["GITHUB_REPOSITORY"], os.environ["ENVIRONMENT"] + + + def api(path): + done = subprocess.run(["gh", "api", path], capture_output=True, text=True) + if done.returncode != 0: + sys.exit(f"::error::`gh api {path}` failed, so nothing is uploaded: {done.stderr.strip()}") + return json.loads(done.stdout) + + + settings = api(f"repos/{repo}/environments/{environment}") + reviewers = [rule for rule in settings.get("protection_rules") or [] + if rule.get("type") == "required_reviewers" and rule.get("reviewers")] + policy = settings.get("deployment_branch_policy") or {} + limits = api(f"repos/{repo}/environments/{environment}/deployment-branch-policies?per_page=100") + rules = sorted((p.get("type"), p.get("name")) for p in limits.get("branch_policies") or []) + if not reviewers: + sys.exit(f"::error::the {environment} environment names no required reviewer now") + if policy.get("custom_branch_policies") is not True or rules != [("tag", "v*")]: + sys.exit(f"::error::the {environment} environment's deployment limit is now {rules}, " + "not the one rule ('tag', 'v*'), so nothing is uploaded") + approved = [review["user"]["login"] + for review in api(f"repos/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}/approvals") + if review.get("state") == "approved" + and any(e.get("name") == environment for e in review.get("environments") or [])] + if not approved: + sys.exit(f"::error::this run holds no approval of a deployment to {environment}, " + "so nothing is uploaded") + print(f"{environment}: a reviewer, the tags v* alone, and approved by {', '.join(approved)}") + PY + + - name: the tag v still names this commit + env: + GH_TOKEN: ${{ github.token }} + run: | + object="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/v$VERSION" \ + --jq 'select(.object.type == "tag") | .object.sha')" || object= + commit= + if [ -n "$object" ]; then + commit="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object" \ + --jq 'select(.object.type == "commit") | .object.sha')" || commit= + fi + if [ "$commit" != "$GITHUB_SHA" ]; then + echo "::error::the annotated tag v$VERSION no longer names $GITHUB_SHA (it names '$commit'), so nothing is uploaded" + exit 1 + fi + echo "v$VERSION still names $GITHUB_SHA" + + - name: the index holds no file of this version but the verified wheel + run: | + python3 - <<'PY' + import json, os, sys, urllib.error, urllib.request + + index, version = os.environ["INDEX"], os.environ["VERSION"] + url = f"{os.environ['JSON_BASE']}{version}/json" + wanted = {os.environ["WHEEL"]: os.environ["WHEEL_SHA256"]} + try: + with urllib.request.urlopen(url, timeout=30) as response: + release = json.load(response) + except urllib.error.HTTPError as error: + if error.code != 404: + raise + print(f"{index} holds no file of openreposhape {version} yet") + sys.exit(0) + held = {f["filename"]: f["digests"]["sha256"] for f in release["urls"]} + other = {name: digest for name, digest in held.items() if wanted.get(name) != digest} + if other: + sys.exit(f"::error::{index} already holds {other} for openreposhape {version}, " + "which the build job did not verify, so nothing is uploaded") + yanked = sorted(f["filename"] for f in release["urls"] if f.get("yanked")) + if yanked: + sys.exit(f"::error::{index} holds {yanked} at the verified digest, but yanked; " + "un-yank it on the index, or release a new version") + print(f"{index} holds {sorted(held)}, at the verified digest; skip-existing uploads nothing new") + PY + + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + skip-existing: true + print-hash: true + + # AFTER THE UPLOAD, PyPI SERVES EXACTLY THE VERIFIED DIGEST: the + # bytes TestPyPI served and the install check ran. + - name: the index serves exactly the verified wheel + run: | + python3 - <<'PY' + import json, os, sys, time, urllib.error, urllib.request + + index, version = os.environ["INDEX"], os.environ["VERSION"] + url = f"{os.environ['JSON_BASE']}{version}/json" + wanted = {os.environ["WHEEL"]: os.environ["WHEEL_SHA256"]} + served = None + for attempt in range(1, 21): + try: + with urllib.request.urlopen(url, timeout=30) as response: + release = json.load(response) + served = {f["filename"]: f["digests"]["sha256"] for f in release["urls"]} + if served == wanted: + if any(f.get("yanked") for f in release["urls"]): + sys.exit(f"::error::{index} serves the verified wheel, yanked") + break + print(f"read {attempt}: {index} serves {served} so far") + except (urllib.error.URLError, TimeoutError, ConnectionError) as error: + code = getattr(error, "code", None) + if code is not None and code != 404 and code < 500: + raise + print(f"read {attempt}: {url} did not answer yet ({error})") + time.sleep(15) + else: + sys.exit(f"::error::after 20 reads {index} serves {served}, and the build job " + f"verified {wanted}") + print(f"{index} serves exactly the verified wheel: {served}") + PY diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7928afaf..eac56645 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -124,11 +124,14 @@ jobs: # anywhere that would have caught a typo in either: the workflow # everybody here runs, on every push and pull request, was linted by # nothing. Handing actionlint its own path alongside the two templates - # closes that gap without touching what it already checked. - - name: actionlint — this workflow and the two it ships into a project + # closes that gap without touching what it already checked. The + # release workflow (#563) is linted here too, on every pull request, + # because nothing else runs it before a release is dispatched. + - name: actionlint — this workflow, the release workflow, and the two it ships into a project run: >- ./actionlint .github/workflows/tests.yml + .github/workflows/release.yml templates/assembly-root/.github/workflows/validate.yml templates/family-root/.github/workflows/validate.yml diff --git a/pyproject.toml b/pyproject.toml index 7e1a364b..4791b545 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,17 @@ # into the package declaratively. Its wheels take their file times from # `SOURCE_DATE_EPOCH`, and the `package` job builds twice on one runner at # the commit's time and compares the two files; that is all it shows. +# +# THE RELEASE IS WHERE THE WHEEL IS SHOWN PATH-INDEPENDENT +# (`.github/workflows/release.yml`, #563). This pin fixes hatchling alone, +# so a build that installs it here, in pip's isolated build environment, +# resolves hatchling's own requirements (packaging, pathspec, pluggy, +# trove-classifiers, and tomli below 3.11) on the day it runs. The release +# builds from `.github/release-build-requirements.txt`, which names +# hatchling at this pin and each of those five by digest, twice, from two +# `git archive` extractions of the tagged commit in two directories, with +# `SOURCE_DATE_EPOCH` at the commit's time, and uploads nothing unless the +# two builds are one file. requires = ["hatchling==1.27.0"] build-backend = "hatchling.build" diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 00000000..845a4d81 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,902 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`.github/workflows/release.yml`: what keeps the release safe (#563). + +OFFLINE. The workflow uploads to TestPyPI and PyPI, and nothing here runs +it. What can be checked with no network is the file itself, read with +`repo_shape.load_yaml`, the fail-closed reader for the YAML subset every +file of this standard is written in, so a construct outside that subset +fails here first. And one of its scripts is RUN: the step that writes the +stamp, against the committed `src/openreposhape/_release.py`, and the file +it writes is read back through the package's own `standard.read_stamp`. + +The properties, each a test below: a dispatch is the only trigger; only +the two upload jobs can mint an identity token, each in its own +environment and on a `v*` tag alone; every action is pinned by a full +commit; the build refuses a tag that is not `v`, annotated, at the +run's commit, a commit on neither main nor its release branch, and a commit +whose own tree has not passed the `tests` job; and every tool the release +installs comes from a hash lock, by digest. +RV599's mutations each fail one of them: a condition or an early exit +added to a step, a check dropped from one upload job, a credential kept, a +`PIP_*` index added. Four steps run as scripts here: the stamp, the wheel +check over a wheel made from HEAD's own blobs, and the main-or-release- +branch check and the `tests` gate, each under a `gh` that answers as the +forge does. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import json +import os +import re +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +from conftest import REPO +from repo_shape import load_yaml + +WORKFLOWS = REPO / ".github" / "workflows" +WORKFLOW = WORKFLOWS / "release.yml" +BUILD_LOCK = REPO / ".github" / "release-build-requirements.txt" +PIPX_LOCK = REPO / ".github" / "release-pipx-requirements.txt" +STAMP = REPO / "src" / "openreposhape" / "_release.py" + +#: Each upload job, and the one environment it deploys to. +UPLOADS = {"testpypi": "testpypi", "pypi": "pypi"} + +#: The gate this repository lands by: `tests`, which `tests.yml` runs on +#: every trigger since #597. Named here, never read from a run, so a job +#: that goes missing is a refusal and not a smaller gate. +GATE = ("tests",) + +#: The guard on both upload jobs: a `v*` tag, nothing else. +ON_A_V_TAG = "startsWith(github.ref, 'refs/tags/v')" + +#: `owner/repository[/path]@<40 hex>`: an action pinned by a full commit. +COMMIT_PIN = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[0-9a-f]{40}") + +#: A `uses:` line as written: the reference, then its release tag as a +#: comment, the way `tests.yml` writes every one. +USES_LINE = re.compile(r"^\s*(?:- )?uses:\s*(\S+)(.*)$") + +#: The release a stamp test writes, and the shapes of its commit and digest. +RELEASE = {"VERSION": "1.1.0", "TAG": "v1.1.0", "COMMIT": "a" * 40, + "DIGEST": "b" * 64} + + +@pytest.fixture(scope="module") +def workflow() -> dict: + return load_yaml(WORKFLOW) + + +def job_steps(workflow: dict, job: str) -> list: + return workflow["jobs"][job]["steps"] + + +def named_step(workflow: dict, job: str, prefix: str) -> dict: + """The one step of `job` whose name starts with `prefix`.""" + [found] = [step for step in job_steps(workflow, job) + if step.get("name", "").startswith(prefix)] + return found + + +def heredoc(script: str) -> str: + """The Python a `run:` script hands to `python3 ... <<'PY'`.""" + lines = script.splitlines() + [start] = [at for at, line in enumerate(lines) + if line.rstrip().endswith("<<'PY'")] + return "\n".join(lines[start + 1:lines.index("PY", start)]) + "\n" + + +def commands(script: str) -> list: + """A `run:` script's command lines as the shell reads them: each + continuation joined to the line it continues, comment lines dropped.""" + joined = re.sub(r"\s*\\\n\s*", " ", script) + return [line.strip() for line in joined.splitlines() + if line.strip() and not line.strip().startswith("#")] + + +def lock_entries(path: Path) -> dict: + """`name==version ; marker` -> its sha256 digests, from a hash lock; + continuation lines joined, comment lines dropped.""" + text = "\n".join(line for line in path.read_text(encoding="utf-8") + .splitlines() if not line.lstrip().startswith("#")) + entries = {} + for entry in re.sub(r"\\\n", " ", text).splitlines(): + if entry.strip(): + requirement = entry.split("--hash=")[0].strip() + entries[requirement] = re.findall(r"--hash=sha256:(\S+)", entry) + return entries + + +# --- the trigger and the token ------------------------------------------------- + +def test_a_dispatch_with_a_version_is_the_only_trigger(workflow): + """Nothing runs on a push, a tag or a pull request: an upload is an act + Brett Heap approves. Nothing is granted by default, and a running + release is never cancelled halfway.""" + assert list(workflow["on"]) == ["workflow_dispatch"] + inputs = workflow["on"]["workflow_dispatch"]["inputs"] + assert list(inputs) == ["version"] + assert inputs["version"]["required"] is True + assert inputs["version"]["type"] == "string" + assert workflow["permissions"] == {} + assert workflow["concurrency"]["cancel-in-progress"] is False + + +def test_only_the_two_upload_jobs_can_mint_an_identity_token(workflow): + """`id-token: write` is trusted publishing's OIDC grant, on the two + upload jobs and on no other. Every job states its own permissions, and + every other grant reads.""" + jobs = workflow["jobs"] + assert set(jobs) == {"build", "testpypi", "install", "pypi"} + assert "id-token" not in workflow["permissions"] + minting = set() + for name, job in jobs.items(): + granted = job.get("permissions") + assert isinstance(granted, dict), name + if "id-token" in granted: + minting.add(name) + assert granted["id-token"] == "write", name + others = {scope: level for scope, level in granted.items() + if scope != "id-token"} + assert set(others.values()) <= {"read"}, (name, others) + assert minting == set(UPLOADS) + + +def test_each_upload_runs_in_its_own_environment_on_a_v_tag_alone(workflow): + """`testpypi` and `pypi`, each the environment Brett Heap limits to the + tags `v*`, and each job guarded by a `v*` tag besides. No other job + names an environment, and PyPI waits for TestPyPI and every leg of the + install check.""" + for name, job in workflow["jobs"].items(): + if name in UPLOADS: + assert job["environment"]["name"] == UPLOADS[name] + assert job["if"] == ON_A_V_TAG + else: + assert "environment" not in job, name + assert workflow["jobs"]["testpypi"]["needs"] == "build" + assert workflow["jobs"]["pypi"]["needs"] == ["build", "testpypi", + "install"] + + +def test_a_branch_dispatch_rehearses_and_both_environments_admit_v_tags_alone( + workflow): + """A branch is the rehearsal: the install check runs over the build's + own wheel when the testpypi job is skipped on a branch, never when it + failed. The build job refuses unless both environments' deployment + limit is the one rule, the tags `v*`, and each upload job reads that + and this run's approval again at use time.""" + assert workflow["jobs"]["install"]["if"] == ( + "${{ !cancelled() && needs.build.result == 'success' && " + "(needs.testpypi.result == 'success' || " + "(needs.testpypi.result == 'skipped' && " + "!startsWith(github.ref, 'refs/tags/'))) }}") + rule = ('if policy.get("custom_branch_policies") is not True ' + 'or rules != [("tag", "v*")]:') + build = heredoc(named_step(workflow, "build", "both environments")["run"]) + assert 'for environment in ("testpypi", "pypi"):' in build + assert rule in build + assert "if not reviewers:" in build + for job in UPLOADS: + at_use = named_step(workflow, job, "this environment still") + assert at_use["env"]["ENVIRONMENT"] == UPLOADS[job] + assert rule in heredoc(at_use["run"]) + assert "if not reviewers:" in heredoc(at_use["run"]) + assert "if not approved:" in heredoc(at_use["run"]) + + +# --- what the workflow runs ---------------------------------------------------- + +def test_every_action_is_pinned_by_a_full_commit(workflow): + """A tag can be moved after review and a commit cannot, which matters + most in the one workflow whose output is published. Each `uses:` line + carries its release tag as a comment, and checkout and setup-python sit + at the commits `tests.yml` pins them to.""" + parsed = [step["uses"] for job in workflow["jobs"].values() + for step in job["steps"] if "uses" in step] + written = [USES_LINE.match(line) for line in + WORKFLOW.read_text(encoding="utf-8").splitlines()] + written = [found for found in written if found] + assert sorted(found.group(1) for found in written) == sorted(parsed) + for found in written: + assert COMMIT_PIN.fullmatch(found.group(1)), found.group(0) + assert re.fullmatch(r"\s+# v\S+", found.group(2)), found.group(0) + tests = load_yaml(WORKFLOWS / "tests.yml") + pinned = {step["uses"] for job in tests["jobs"].values() + for step in job["steps"] if "uses" in step} + for action in ("actions/checkout@", "actions/setup-python@"): + here = {ref for ref in parsed if ref.startswith(action)} + assert here, action + assert here == {ref for ref in pinned if ref.startswith(action)} + + +def test_the_build_refuses_a_tag_that_is_not_the_annotated_v_version_at_this_commit( + workflow): + """On a tag, the ref must be `refs/tags/v`, and the forge's + tag must be an annotated tag object naming this run's commit; git's own + copy of it must agree. Any other ref but a branch refuses.""" + script = named_step(workflow, "build", "the dispatched ref")["run"] + assert '"refs/tags/$tag") ;;' in script + assert "refs/heads/*)" in script + assert 'git/ref/tags/$tag' in script + assert "select(.object.type == \"tag\")" in script + assert '[ "$commit" != "$GITHUB_SHA" ]' in script + assert 'git cat-file -t "refs/tags/$tag"' in script + version = named_step(workflow, "build", "the version is")["run"] + assert ("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + in version) + for job in UPLOADS: + again = named_step(workflow, job, "the tag v still")["run"] + assert '[ "$commit" != "$GITHUB_SHA" ]' in again + + +def test_the_build_refuses_a_commit_without_a_passing_tests_job(workflow): + """RV599, MUST 1 and MUST 2: the gate requires the `tests` job alone, + which `tests.yml` runs on every trigger since #597. It must have passed + in a `push` or `workflow_dispatch` run of `tests.yml` + whose head is the commit (a `pull_request` run tests a merge, another + tree), read from that run's own jobs, never from the commit's check + runs, which an Actions token can create. The gate names the job; it is + a job of `tests.yml` and does not rename itself, so its name is its + id.""" + build = workflow["jobs"]["build"] + assert "checks" not in build["permissions"] + assert build["permissions"]["actions"] == "read" + step = named_step(workflow, "build", "the commit carries") + assert step["name"] == "the commit carries a passing tests job" + script = heredoc(step["run"]) + assert GATE == ("tests",) + assert 'gate, workflow = ("tests",), ".github/workflows/tests.yml"' in script + assert ('if run.get("path") == workflow and run.get("head_sha") == sha\n' + ' and run.get("event") in ("push", "workflow_dispatch")]' + in script) + assert '/jobs?filter=all&per_page=100", "jobs")' in script + assert "check-runs" not in script + assert "check_suite" not in script + assert 'if newest["conclusion"] != "success":' in script + tests = load_yaml(WORKFLOWS / "tests.yml") + for name in GATE: + assert name in tests["jobs"], name + assert "name" not in tests["jobs"][name], name + + +#: A `gh` that answers the gate's two reads from a JSON file, as the forge +#: does through `--jq '.[]'`, one object per line: the commit's runs +#: for `actions/runs?head_sha=`, and a run's jobs for `actions/runs// +#: jobs`. Each call is logged. +RUNS_GH = """\ +import json, os, re, sys +args = sys.argv[1:] +with open(os.environ["GH_LOG"], "a", encoding="utf-8") as log: + log.write(" ".join(args) + "\\n") +path = next(arg for arg in args if arg.startswith("repos/")) +with open(os.environ["GH_DATA"], encoding="utf-8") as handle: + data = json.load(handle) +found = re.search(r"/actions/runs/([0-9]+)/jobs", path) +for row in data["jobs"].get(found.group(1), []) if found else data["runs"]: + print(json.dumps(row)) +""" + +#: The commit every gate case is about. +GATE_SHA = "c" * 40 + + +def a_run(number: int, event: str = "push", head_sha: str = GATE_SHA, + path: str = ".github/workflows/tests.yml") -> dict: + return {"id": number, "event": event, "head_sha": head_sha, "path": path} + + +def a_job(when: str = "2026-10-15T17:00:00Z", conclusion: str = "success", + status: str = "completed", name: str = "tests") -> dict: + return {"name": name, "status": status, "conclusion": conclusion, + "completed_at": when if status == "completed" else None, + "html_url": f"https://github.invalid/job/{name}/{when}"} + + +@pytest.mark.skipif(os.name == "nt", reason="the step runs on ubuntu; this " + "runs it under a `gh` started by its shebang") +@pytest.mark.parametrize("runs, jobs, code, says", [ + ([a_run(1)], {1: [a_job()]}, 0, "tests: success at 2026-10-15T17:00:00Z"), + ([a_run(1, "workflow_dispatch")], {1: [a_job()]}, 0, "tests: success"), + ([a_run(1, "pull_request")], {1: [a_job()]}, 1, "`tests` has not run"), + ([a_run(1, head_sha="d" * 40)], {1: [a_job()]}, 1, "`tests` has not run"), + ([a_run(1, path=".github/workflows/other.yml")], {1: [a_job()]}, 1, + "`tests` has not run"), + ([a_run(1)], {1: [a_job(conclusion="skipped")]}, 1, "`tests` has not run"), + ([a_run(1)], {1: [a_job(name="lint")]}, 1, "`tests` has not run"), + ([a_run(1)], {1: [a_job(status="in_progress", conclusion=None)]}, 1, + "`tests` is in_progress"), + ([a_run(1), a_run(2, "workflow_dispatch")], + {1: [a_job("2026-10-15T17:00:00Z")], + 2: [a_job("2026-10-15T18:00:00Z", "failure")]}, 1, + "`tests`'s newest run on " + GATE_SHA + " ended failure"), + ([a_run(1), a_run(2, "workflow_dispatch")], + {1: [a_job("2026-10-15T17:00:00Z", "failure")], + 2: [a_job("2026-10-15T18:00:00Z")]}, 0, + "tests: success at 2026-10-15T18:00:00Z"), + ([a_run(1)], {1: [a_job(conclusion="cancelled")]}, 1, + "`tests`'s newest run on " + GATE_SHA + " ended cancelled"), + ([a_run(1), a_run(2, "workflow_dispatch")], + {1: [a_job("2026-10-15T17:00:00Z", "cancelled")], + 2: [a_job("2026-10-15T18:00:00Z")]}, 0, + "tests: success at 2026-10-15T18:00:00Z"), +], ids=["a-push-run-passed", "a-dispatch-run-passed", "a-pull-request-run-alone", + "a-run-of-another-head", "a-run-of-another-workflow", "skipped", + "no-tests-job", "still-in-progress", "older-success-newer-failure", + "older-failure-newer-success", "a-landing-cancelled-the-push-run", + "a-dispatch-after-a-cancelled-push-run"]) +def test_the_gate_decides_from_the_runs_jobs(workflow, tmp_path, runs, jobs, + code, says): + """RV599, SHOULD 6: the gate's own script under a `gh` that answers as + the forge does. Only a push or dispatch run of tests.yml at the commit + counts, and only its jobs are read; a job still running refuses; and + the newest finished `tests` decides, whichever way the older went.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "gh").write_text(f"#!{sys.executable}\n" + RUNS_GH, + encoding="utf-8") + (bin_dir / "gh").chmod(0o755) + data = tmp_path / "forge.json" + data.write_text(json.dumps({"runs": runs, "jobs": {str(k): v for k, v + in jobs.items()}}), + encoding="utf-8") + log = tmp_path / "gh.log" + log.write_text("", encoding="utf-8") + script = tmp_path / "gate.py" + script.write_text(heredoc(named_step(workflow, "build", + "the commit carries")["run"]), + encoding="utf-8") + done = subprocess.run( + [sys.executable, "-I", str(script)], capture_output=True, text=True, + check=False, env={**os.environ, "GH_LOG": str(log), "GH_DATA": str(data), + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "GITHUB_REPOSITORY": "opensoft/openRepoShape", + "GITHUB_SHA": GATE_SHA, "VERSION": "1.1.0"}) + assert done.returncode == code, done.stdout + done.stderr + assert says in done.stdout + done.stderr + if "has not run" in says or " ended " in says: + # RV599, SHOULD 7: a refusal names the dispatch that answers it. + for remedy in ("`gh workflow run tests.yml --ref `", + "`gh workflow run tests.yml --ref v1.1.0`", + "`--ref main` tests main's current head"): + assert remedy in done.stdout + done.stderr, remedy + counted = [run["id"] for run in runs + if run["event"] in ("push", "workflow_dispatch") + and run["head_sha"] == GATE_SHA + and run["path"] == ".github/workflows/tests.yml"] + asked = [int(found) for found in re.findall( + r"/actions/runs/([0-9]+)/jobs\?filter=all", log.read_text( + encoding="utf-8"))] + assert asked == counted + + +def test_the_digest_is_the_checkouts_own_and_the_forges(workflow): + """The stamp's digest comes from the checkout's own + `scripts/repo_shape.py` and must equal the forge's reading of the same + commit, after the checkout is shown clean.""" + names = [step.get("name", "") for step in job_steps(workflow, "build")] + clean = names.index("the checkout is this commit, and clean") + digest = names.index("the commit's tree digest is the forge's") + assert clean < digest + script = heredoc(named_step(workflow, "build", "the commit's tree")["run"]) + assert 'sys.path[:0] = [str(here / "scripts"), str(here / "src")]' \ + in script + assert "repo_shape.tree_digest(here, sha)" in script + assert "repo_shape.tree_digest_from_gh(repo, sha)" in script + assert "if local != forge:" in script + + +# --- the tools, by digest ------------------------------------------------------- + +def test_every_pip_install_is_from_a_hash_lock_by_digest(workflow): + """Every `pip install` the workflow runs, read as the shell reads it + (continuations joined, comments dropped), is `--require-hashes`, wheels + only, from one of the two locks: the build's tools in the build job, + pipx in the install check, and nothing else anywhere.""" + used = set() + for name, job in workflow["jobs"].items(): + for step in job["steps"]: + for command in commands(step.get("run", "")): + if not re.search(r"\bpip install\b", command): + continue + assert "--require-hashes" in command, command + assert "--only-binary :all:" in command, command + [lock] = re.findall(r"-r (\S+)", command) + used.add((name, lock)) + assert used == { + ("build", BUILD_LOCK.relative_to(REPO).as_posix()), + ("install", PIPX_LOCK.relative_to(REPO).as_posix())} + + +@pytest.mark.parametrize("lock", [BUILD_LOCK, PIPX_LOCK], ids=["build", "pipx"]) +def test_every_lock_entry_is_one_version_named_by_its_sha256(lock): + """A lock in which every requirement is pinned to one version and named + by its sha256.""" + entries = lock_entries(lock) + assert entries + for requirement, digests in entries.items(): + assert re.fullmatch(r"[a-z0-9-]+==[0-9][0-9A-Za-z.]*( ; .+)?", + requirement), requirement + assert digests, requirement + for digest in digests: + assert re.fullmatch(r"[0-9a-f]{64}", digest), requirement + + +def test_the_build_lock_holds_hatchling_at_its_pin_and_its_requirements(): + """RV590, SHOULD 6: `pyproject.toml` pins hatchling alone, so the lock + names hatchling at that same pin, and each of its own requirements, and + the `build` frontend the release runs it with.""" + tomllib = pytest.importorskip("tomllib") + data = tomllib.loads((REPO / "pyproject.toml").read_text(encoding="utf-8")) + [backend] = data["build-system"]["requires"] + names = {requirement.split("==")[0]: requirement.split(" ;")[0] + for requirement in lock_entries(BUILD_LOCK)} + assert names["hatchling"] == backend + for name in ("packaging", "pathspec", "pluggy", "trove-classifiers", + "tomli", "build"): + assert name in names, name + assert "pipx" in {requirement.split("==")[0] + for requirement in lock_entries(PIPX_LOCK)} + + +def test_the_wheel_is_built_twice_from_two_extractions_and_compared(workflow): + """A wheel only, from two `git archive` extractions of the commit in + two directories, at the commit's own time, with the locked backend; + the two must be one file before anything else reads them.""" + extract = named_step(workflow, "build", "extract the commit twice")["run"] + assert extract.count("git archive") == 1 + assert 'for tree in "$tree_a" "$tree_b"; do' in extract + build = named_step(workflow, "build", "build the wheel")["run"] + assert 'SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$GITHUB_SHA")"' \ + in build + assert "-m build --wheel --no-isolation" in build + assert "--sdist" not in build + assert ('if ! cmp "$RUNNER_TEMP/dist-a/$wheel" "$RUNNER_TEMP/dist-b/$wheel"; then' + in commands(build)) + stamp = commands(named_step(workflow, "build", "write the stamp")["run"]) + assert stamp[0] == ('python3 -I - "$TREE_A/src/openreposhape/_release.py" ' + '"$TREE_B/src/openreposhape/_release.py" <<\'PY\'') + + +# --- the stamp, written and read back -------------------------------------------- + +def write_stamp(workflow: dict, tmp_path: Path, release: dict): + """The stamp step's own script, run as the workflow runs it over two + copies of the committed `_release.py`.""" + script = tmp_path / "stamp.py" + script.write_text(heredoc(named_step( + workflow, "build", "write the stamp")["run"]), encoding="utf-8") + copies = [tmp_path / side / "_release.py" for side in ("a", "b")] + for copy in copies: + copy.parent.mkdir() + copy.write_bytes(STAMP.read_bytes()) + env = {**os.environ, "VERSION": release["VERSION"], + "GITHUB_SHA": release["COMMIT"], "DIGEST": release["DIGEST"]} + done = subprocess.run([sys.executable, "-I", str(script), + *map(str, copies)], env=env, capture_output=True, + text=True, check=False) + return done, copies + + +def test_the_stamp_the_workflow_writes_is_one_the_package_reads( + workflow, tmp_path, monkeypatch): + """Both copies carry the release's four values, hatchling finds the + version in it the way it reads the committed one, and every other line + is the committed file's.""" + done, copies = write_stamp(workflow, tmp_path, RELEASE) + assert done.returncode == 0, done.stderr + monkeypatch.syspath_prepend(str(REPO / "src")) + monkeypatch.setattr(sys, "dont_write_bytecode", True) + standard = importlib.import_module("openreposhape.standard") + committed = STAMP.read_text(encoding="utf-8").splitlines() + for copy in copies: + spec = importlib.util.spec_from_file_location( + f"stamped_{copy.parent.name}", copy) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + stamp = standard.read_stamp(module) + assert stamp.stamped + assert (stamp.version, stamp.tag, stamp.commit, stamp.digest) == ( + RELEASE["VERSION"], RELEASE["TAG"], RELEASE["COMMIT"], + RELEASE["DIGEST"]) + text = copy.read_text(encoding="utf-8") + found = re.search(r"(?i)^(__version__|VERSION) *= *(['\"])v?" + r"(?P.+?)\2", text, re.M) + assert found.group("version") == RELEASE["VERSION"] + changed = [(old, new) for old, new in + zip(committed, text.splitlines()) if old != new] + assert len(text.splitlines()) == len(committed) + assert sorted(new for _, new in changed) == sorted( + f'{name} = "{value}"' for name, value in RELEASE.items()) + + +@pytest.mark.parametrize("changed", [ + {"VERSION": "0.0.0"}, + {"VERSION": "1.1"}, + {"VERSION": "1.1.0rc1"}, + {"COMMIT": "A" * 40}, + {"DIGEST": "b" * 63}, +]) +def test_the_stamp_step_refuses_a_value_no_release_carries( + workflow, tmp_path, changed): + """The unstamped 0.0.0, a version that is not X.Y.Z, and a commit or a + digest of the wrong shape are each refused, and nothing is written.""" + done, copies = write_stamp(workflow, tmp_path, {**RELEASE, **changed}) + assert done.returncode != 0 + for copy in copies: + assert copy.read_bytes() == STAMP.read_bytes() + + +def test_the_stamp_step_refuses_a_file_already_stamped(workflow, tmp_path): + """It stamps the committed, unstamped file and nothing else: a second + run over its own output finds no `VERSION = "0.0.0"` to replace.""" + done, copies = write_stamp(workflow, tmp_path, RELEASE) + assert done.returncode == 0, done.stderr + stamped = copies[0].read_bytes() + script = tmp_path / "stamp.py" + env = {**os.environ, "VERSION": "1.1.1", "GITHUB_SHA": RELEASE["COMMIT"], + "DIGEST": RELEASE["DIGEST"]} + again = subprocess.run([sys.executable, "-I", str(script), + str(copies[0])], env=env, capture_output=True, + text=True, check=False) + assert again.returncode != 0 + assert copies[0].read_bytes() == stamped + + +# --- the install check ------------------------------------------------------------ + +def test_the_install_check_runs_on_three_systems_and_python_3_9(workflow): + """ubuntu, macOS and Windows, and the oldest Python the package + supports; every pip it runs reads PyPI alone, so git-filter-repo never + comes from TestPyPI, and TestPyPI's wheel is taken from its own file + host by the recorded digest.""" + install = workflow["jobs"]["install"] + legs = install["strategy"]["matrix"]["include"] + assert {leg["os"] for leg in legs} == {"ubuntu-latest", "macos-latest", + "windows-latest"} + assert {"os": "ubuntu-latest", "python": "3.9"} in legs + assert install["strategy"]["fail-fast"] is False + assert install["env"]["PIP_INDEX_URL"] == "https://pypi.org/simple/" + for step in install["steps"]: + assert "--index-url" not in step.get("run", ""), step.get("name") + assert "--extra-index-url" not in step.get("run", ""), step.get("name") + fetch = named_step(workflow, "install", "fetch the wheel from TestPyPI") + assert 'parts.hostname != "test-files.pythonhosted.org"' in fetch["run"] + names = [step.get("name", "") for step in install["steps"]] + verified = names.index("the wheel is the one the build job verified") + assert verified < names.index( + "pipx installs the wheel into a fresh environment, its dependency " + "from PyPI") + + +def test_the_header_says_how_to_release(workflow): + """No `docs/release-notes/v1.1.0.md` exists yet, so the header carries + the steps the docs pull request will point at: Brett Heap's acts, with + the trusted publisher's environment REQUIRED and why (RV599, SHOULD 1), + and the limits carried over from openDox-code.""" + text = WORKFLOW.read_text(encoding="utf-8") + assert "# RELEASING TO PyPI." in text + assert "gh workflow run release.yml --ref v -f version=" \ + in text + assert "# A DISPATCH ON A BRANCH IS THE REHEARSAL, AND IT CANNOT DEPLOY." \ + in text + assert "# THE ENVIRONMENT IS REQUIRED in both" in text + assert "# ACCEPTED LIMITS, carried over from openDox-code's release.yml" \ + in text + assert "lane\n# sessions act as brettheap" in text + + +# --- RV599, SHOULD 2: the commit is on main or its release branch ------------------- + +ON_BRANCH = "the commit is on main or on the version's release branch" + +#: A `gh` that answers `compare` from the environment, as the forge would, +#: and logs each call: STATUS_MAIN for main, STATUS_RELEASE for any other +#: branch, and a 404 where the one it reads is empty. +COMPARE_GH = """#!/bin/sh +echo "$*" >> "$GH_LOG" +case "$2" in + *...refs/heads/main) status="$STATUS_MAIN" ;; + *) status="$STATUS_RELEASE" ;; +esac +if [ -z "$status" ]; then echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi +echo "$status" +""" + + +def test_the_commit_must_be_on_main_or_its_release_branch(workflow): + """After the tag's checks and before anything from the checkout runs: + the forge's compare must call `main` or `release/v.` + identical to the commit or ahead of it, by `refs/heads/`.""" + names = [step.get("name", "") for step in job_steps(workflow, "build")] + assert names.index(ON_BRANCH) == names.index( + "the dispatched ref is the annotated tag v at this commit, " + "or a branch to rehearse on") + 1 + lines = commands(named_step(workflow, "build", ON_BRANCH)["run"]) + assert 'for branch in main "release/v${VERSION%.*}"; do' in lines + assert ('status="$(gh api "repos/$GITHUB_REPOSITORY/compare/' + '$GITHUB_SHA...refs/heads/$branch" --jq .status 2>/dev/null)" ' + '|| status=') in lines + assert "identical|ahead)" in lines + + +@pytest.mark.skipif(os.name == "nt", reason="the step is bash on ubuntu; " + "this runs it under a POSIX `gh` stub") +@pytest.mark.parametrize("ref, version, main, release, code, asked", [ + ("refs/tags/v1.1.0", "1.1.0", "ahead", "", 0, ["main"]), + ("refs/tags/v1.1.0", "1.1.0", "identical", "", 0, ["main"]), + ("refs/tags/v1.0.1", "1.0.1", "diverged", "identical", 0, + ["main", "release/v1.0"]), + ("refs/tags/v1.1.0", "1.1.0", "diverged", "", 1, ["main", "release/v1.1"]), + ("refs/tags/v1.1.0", "1.1.0", "behind", "diverged", 1, + ["main", "release/v1.1"]), + ("refs/heads/rehearse", "1.1.0", "", "", 0, []), +]) +def test_the_on_branch_step_answers_from_the_forges_compare( + workflow, tmp_path, ref, version, main, release, code, asked): + """Run with a `gh` that answers as the forge did for v1.0.1 (diverged + from main, identical to `release/v1.0`) and for an unmerged head + (diverged, no release branch); a branch rehearsal asks nothing.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "gh").write_text(COMPARE_GH, encoding="utf-8") + (bin_dir / "gh").chmod(0o755) + script = tmp_path / "step.sh" + script.write_text(named_step(workflow, "build", ON_BRANCH)["run"], + encoding="utf-8") + log = tmp_path / "gh.log" + log.write_text("", encoding="utf-8") + env = {**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "GH_LOG": str(log), "STATUS_MAIN": main, "STATUS_RELEASE": release, + "GITHUB_REF": ref, "GITHUB_REF_NAME": ref.rsplit("/", 1)[-1], + "GITHUB_SHA": "c" * 40, "GITHUB_REPOSITORY": "opensoft/openRepoShape", + "VERSION": version} + done = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", + str(script)], env=env, capture_output=True, + text=True, check=False) + assert done.returncode == code, done.stdout + done.stderr + called = [line.split("refs/heads/")[1].split()[0] + for line in log.read_text(encoding="utf-8").splitlines()] + assert called == asked + + +# --- RV599, SHOULD 3: what the mutations got past ------------------------------------- + +#: The only steps that run conditionally, and their conditions: the tag +#: path's TestPyPI fetch, the rehearsal's own wheel, and PowerShell. +STEP_CONDITIONS = { + ("install", "fetch the wheel from TestPyPI"): + "startsWith(github.ref, 'refs/tags/')", + ("install", "take the build's own wheel, on a branch (the rehearsal)"): + "${{ !startsWith(github.ref, 'refs/tags/') }}", + ("install", "PowerShell runs openRepoShape by its name (Windows)"): + "runner.os == 'Windows'", +} + +#: Each upload job's steps, in their order: the artifact, its digest, the +#: environment and this run's approval, the tag, the index before, the +#: upload, the index after. +UPLOAD_STEPS = [ + "actions/download-artifact", + "dist holds the wheel the build job verified, and nothing else", + "this environment still asks a reviewer and admits only the tags v*, and " + "this run's deployment to it was approved", + "the tag v still names this commit", + "the index holds no file of this version but the verified wheel", + "pypa/gh-action-pypi-publish", + "the index serves exactly the verified wheel", +] + + +def label(step: dict) -> str: + return step.get("name") or step["uses"].split("@")[0] + + +def test_only_the_intended_steps_and_jobs_run_conditionally(workflow): + """(a) An `if:` on any other step, or another condition on these, + could skip a check; and nothing continues past a failed step.""" + found = {(name, label(step)): step["if"] + for name, job in workflow["jobs"].items() + for step in job["steps"] if "if" in step} + assert found == STEP_CONDITIONS + assert "if" not in workflow["jobs"]["build"] + for name, job in workflow["jobs"].items(): + assert "continue-on-error" not in job, name + for step in job["steps"]: + assert "continue-on-error" not in step, (name, label(step)) + + +def test_every_checkout_keeps_no_credentials(workflow): + """(b) The token is never left in a checkout's git config, where code + the job then runs could read it.""" + checkouts = [step for job in workflow["jobs"].values() + for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@")] + assert len(checkouts) == 2 + for step in checkouts: + assert step["with"]["persist-credentials"] is False + + +def test_no_pip_setting_but_pypi_as_the_install_checks_only_index(workflow): + """(c) `PIP_*` appears in no environment and no script but the install + check's two settings: PyPI as the one index, and no version check.""" + scopes = [workflow.get("env") or {}] + for job in workflow["jobs"].values(): + scopes.append(job.get("env") or {}) + scopes.extend(step.get("env") or {} for step in job["steps"]) + pip = sorted(key for scope in scopes for key in scope + if key.startswith("PIP_")) + assert pip == ["PIP_DISABLE_PIP_VERSION_CHECK", "PIP_INDEX_URL"] + assert workflow["jobs"]["install"]["env"]["PIP_INDEX_URL"] == \ + "https://pypi.org/simple/" + for job in workflow["jobs"].values(): + for step in job["steps"]: + assert "PIP_" not in step.get("run", ""), label(step) + assert "--index-url" not in step.get("run", ""), label(step) + + +@pytest.mark.parametrize("job", sorted(UPLOADS)) +def test_each_upload_job_checks_in_its_fixed_order(workflow, job): + """(d) The digest, the environment and approval, the tag, the index + before, the upload, the index after, in that order; each check is the + same script in both jobs; TestPyPI's upload names TestPyPI and PyPI's + names no other index.""" + steps = job_steps(workflow, job) + assert [label(step) for step in steps] == UPLOAD_STEPS + assert ('echo "$WHEEL_SHA256 dist/$WHEEL" | sha256sum --strict -c -' + in commands(steps[1]["run"])) + other = job_steps(workflow, ({*UPLOADS} - {job}).pop()) + for mine, theirs in zip(steps, other): + assert mine.get("run") == theirs.get("run"), label(mine) + publish = steps[UPLOAD_STEPS.index("pypa/gh-action-pypi-publish")]["with"] + if job == "testpypi": + assert publish == {"repository-url": "https://test.pypi.org/legacy/", + "skip-existing": True, "print-hash": True} + else: + assert publish == {"skip-existing": True, "print-hash": True} + index = {"testpypi": "https://test.pypi.org/pypi/openreposhape/", + "pypi": "https://pypi.org/pypi/openreposhape/"} + assert workflow["jobs"][job]["env"]["JSON_BASE"] == index[job] + + +WHEEL_CHECK = "the wheel holds the commit's own files, byte for byte, and the stamp" + + +def test_the_wheel_is_checked_against_the_commit_before_it_is_recorded( + workflow): + """(e) RV599, SHOULD 5 besides: right after the two builds and before + the digest is recorded, with each of its comparisons.""" + names = [step.get("name", "") for step in job_steps(workflow, "build")] + check = names.index(WHEEL_CHECK) + assert names[check - 1] == ("build the wheel from each extraction, and " + "the two are one file") + assert names[check + 1] == "record the wheel and its sha256" + script = heredoc(named_step(workflow, "build", WHEEL_CHECK)["run"]) + for line in ( + "elif wheel.read(name) != committed(source):", + "for name in sorted(wanted - set(names)):", + "if changed != release or assigned != release:", + "if shown != declared:", + 'if entry_points.sections() != ["console_scripts"]:', + 'if scripts != project["scripts"]:', + 'if described != sorted(["METADATA", "WHEEL", "RECORD", ' + '"entry_points.txt",', + 'if wheel.read(info + "licenses/LICENSE") != committed("LICENSE"):', + "sys.exit(1 if problems else 0)"): + assert line in script, line + + +def fake_wheel(path: Path, sha: str, version: str, digest: str, + change: dict) -> None: + """The wheel the build writes for `sha`, laid out as hatchling lays it + out, from git's own blobs; `change` replaces entries, None drops one.""" + tomllib = pytest.importorskip("tomllib") + + def blob(name: str) -> bytes: + return subprocess.run(["git", "cat-file", "blob", f"{sha}:{name}"], + cwd=REPO, capture_output=True, check=True).stdout + + project = tomllib.loads(blob("pyproject.toml").decode())["project"] + listed = subprocess.run(["git", "ls-tree", "-r", "-z", "--name-only", sha, + "--", "src/openreposhape/"], cwd=REPO, + capture_output=True, check=True).stdout.decode() + files = {name[len("src/"):]: blob(name) + for name in listed.split("\0") if name} + files["openreposhape/setup-project.py"] = blob("setup-project.py") + stamp = files["openreposhape/_release.py"].decode() + for name, old, new in (("VERSION", "0.0.0", version), + ("TAG", "", f"v{version}"), ("COMMIT", "", sha), + ("DIGEST", "", digest)): + stamp = stamp.replace(f'{name} = "{old}"', f'{name} = "{new}"', 1) + files["openreposhape/_release.py"] = stamp.encode() + info = f"openreposhape-{version}.dist-info/" + files[info + "METADATA"] = "".join( + [f"Metadata-Version: 2.4\nName: {project['name']}\n", + f"Version: {version}\nRequires-Python: {project['requires-python']}\n", + *(f"Requires-Dist: {r}\n" for r in project["dependencies"])]).encode() + files[info + "WHEEL"] = b"Wheel-Version: 1.0\nTag: py3-none-any\n" + files[info + "entry_points.txt"] = "".join( + ["[console_scripts]\n", + *(f"{k} = {v}\n" for k, v in project["scripts"].items())]).encode() + files[info + "licenses/LICENSE"] = blob("LICENSE") + files[info + "RECORD"] = b"" + files.update({name.replace("", info): data + for name, data in change.items()}) + with zipfile.ZipFile(path, "w") as wheel: + for name, data in sorted(files.items()): + if data is not None: + wheel.writestr(name, data) + + +@pytest.mark.parametrize("change, code", [ + ({}, 0), + ({"openreposhape/cli.py": b"# not the commit's\n"}, 1), + ({"openreposhape/extra.py": b"import os\n"}, 1), + ({"openreposhape/standard.py": None}, 1), + ({"top_level.txt": b"openreposhape\n"}, 1), + ({"RECORD": None}, 1), + ({"entry_points.txt": b"[console_scripts]\n[gui_scripts]\n"}, 1), +], ids=["the-commits-own", "a-changed-module", "an-extra-module", + "a-missing-module", "an-extra-dist-info-file", "no-record", + "a-second-entry-point-section"]) +def test_the_wheel_check_refuses_any_file_that_is_not_the_commits( + workflow, tmp_path, change, code): + """The step's own script, over a wheel made from this checkout's HEAD: + the commit's own files pass, and a changed, extra or missing module, an + extra or missing `.dist-info/` file, or an entry-point section beside + `console_scripts` refuses (RV599, SHOULD 5).""" + sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=REPO, + capture_output=True, text=True, check=True).stdout.strip() + wheel = tmp_path / "openreposhape-1.1.0-py3-none-any.whl" + fake_wheel(wheel, sha, "1.1.0", "d" * 64, change) + script = tmp_path / "check.py" + script.write_text(heredoc(named_step(workflow, "build", WHEEL_CHECK)["run"]), + encoding="utf-8") + done = subprocess.run([sys.executable, "-I", str(script), str(wheel)], + cwd=REPO, capture_output=True, text=True, check=False, + env={**os.environ, "VERSION": "1.1.0", + "GITHUB_SHA": sha, "DIGEST": "d" * 64}) + assert done.returncode == code, done.stdout + done.stderr + + +def test_a_bare_success_exit_is_the_rehearsal_or_a_404_alone(workflow): + """(f) `exit 0` ends a step early only on a branch rehearsal, inside + its `refs/heads/*)` arm, and `sys.exit(0)` only where an index answers + 404 for a version it holds no file of.""" + found = {} + for name, job in workflow["jobs"].items(): + for step in job["steps"]: + lines = step.get("run", "").splitlines() + for at, line in enumerate(lines): + if re.fullmatch(r"\s*exit 0\s*", line): + assert "refs/heads/*)" in "\n".join(lines[at - 2:at]) + found.setdefault((name, label(step)), []).append("exit 0") + if "sys.exit(0)" in line: + assert "!= 404" in "\n".join(lines[at - 4:at]) + found.setdefault((name, label(step)), []).append( + "sys.exit(0)") + assert found == { + ("build", "the dispatched ref is the annotated tag v at this " + "commit, or a branch to rehearse on"): ["exit 0"], + ("build", ON_BRANCH): ["exit 0"], + ("testpypi", UPLOAD_STEPS[4]): ["sys.exit(0)"], + ("pypi", UPLOAD_STEPS[4]): ["sys.exit(0)"], + } + + +# --- RV599, SHOULD 4: the release workflow is linted on every pull request ---------- + +def test_tests_yml_lints_the_release_workflow(): + """`tests.yml`'s actionlint step hands it this file, so a workflow no + pull request ever runs is still read by the linter on each one.""" + tests = load_yaml(WORKFLOWS / "tests.yml") + [lint] = [step for step in tests["jobs"]["tests"]["steps"] + if step.get("name", "").startswith("actionlint")] + assert ".github/workflows/release.yml" in lint["run"].split()